diff --git a/docs/examples/widgets/collapsible.py b/docs/examples/widgets/collapsible.py new file mode 100644 index 0000000000..9dd1bee51e --- /dev/null +++ b/docs/examples/widgets/collapsible.py @@ -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() diff --git a/docs/examples/widgets/collapsible_custom_symbol.py b/docs/examples/widgets/collapsible_custom_symbol.py new file mode 100644 index 0000000000..d2fa266aa6 --- /dev/null +++ b/docs/examples/widgets/collapsible_custom_symbol.py @@ -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() diff --git a/docs/examples/widgets/collapsible_nested.py b/docs/examples/widgets/collapsible_nested.py new file mode 100644 index 0000000000..d4b65835f7 --- /dev/null +++ b/docs/examples/widgets/collapsible_nested.py @@ -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() diff --git a/docs/widgets/collapsible.md b/docs/widgets/collapsible.md new file mode 100644 index 0000000000..d98ed7b398 --- /dev/null +++ b/docs/widgets/collapsible.md @@ -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] + + + +--- + + +::: textual.widgets.Collapsible + options: + heading_level: 2 diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py index 8c71dfa7fd..0e8b3d262d 100644 --- a/src/textual/widgets/__init__.py +++ b/src/textual/widgets/__init__.py @@ -12,6 +12,7 @@ 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 @@ -43,10 +44,10 @@ from ._tree import Tree from ._welcome import Welcome - __all__ = [ "Button", "Checkbox", + "Collapsible", "ContentSwitcher", "DataTable", "Digits", diff --git a/src/textual/widgets/__init__.pyi b/src/textual/widgets/__init__.pyi index 86f17d13cb..87c2f75f5e 100644 --- a/src/textual/widgets/__init__.pyi +++ b/src/textual/widgets/__init__.pyi @@ -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 diff --git a/src/textual/widgets/_collapsible.py b/src/textual/widgets/_collapsible.py new file mode 100644 index 0000000000..b29216bb4d --- /dev/null +++ b/src/textual/widgets/_collapsible.py @@ -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) diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index bd2ffa863b..0cf24556d6 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -1698,6 +1698,792 @@ ''' # --- +# name: test_collapsible_collapsed + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + CollapsibleApp + + + + + + + + + + Leto + Jessica + Paul + + + + + + + + + + + + + + + + + + + + +  C  Collapse All  E  Expand All  + + + + + ''' +# --- +# name: test_collapsible_custom_symbol + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + CollapsibleApp + + + + + + + + + + >>>TogglevToggle + Hello, world. + + + + + + + + + + + + + + + + + + + + + + + + + + + ''' +# --- +# name: test_collapsible_expanded + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + CollapsibleApp + + + + + + + + + + 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. + +  C  Collapse All  E  Expand All ▇▇ + + + + + ''' +# --- +# name: test_collapsible_nested + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + CollapsibleApp + + + + + + + + + + Toggle + Toggle + + + + + + + + + + + + + + + + + + + + + + + + + + + ''' +# --- +# name: test_collapsible_render + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + CollapsibleApp + + + + + + + + + + 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 + + + + + + + +  C  Collapse All  E  Expand All  + + + + + ''' +# --- # name: test_columns_height ''' diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 36b003bb7e..cc6947696b 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -322,6 +322,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. diff --git a/tests/test_collapsible.py b/tests/test_collapsible.py new file mode 100644 index 0000000000..771e376009 --- /dev/null +++ b/tests/test_collapsible.py @@ -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)