diff --git a/CHANGELOG.md b/CHANGELOG.md index cdd8bd1d4c..ddd8fb60dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - Added `get_loading_widget` to Widget and App customize the loading widget. https://github.com/Textualize/textual/pull/3816 +- Added messages `Collapsible.Expanded` and `Collapsible.Collapsed` that inherit from `Collapsible.Toggled`. https://github.com/Textualize/textual/issues/3824 ## [0.44.1] - 2023-12-4 diff --git a/docs/widgets/collapsible.md b/docs/widgets/collapsible.md index 009f7f760b..417ce114ce 100644 --- a/docs/widgets/collapsible.md +++ b/docs/widgets/collapsible.md @@ -128,7 +128,7 @@ The following example shows `Collapsible` widgets with custom expand/collapse sy ## Messages -This widget posts no messages. +- [Collapsible.Toggled][textual.widgets.Collapsible.Toggled] ## Bindings diff --git a/src/textual/widgets/_collapsible.py b/src/textual/widgets/_collapsible.py index 5901cbc9de..dcd9441e40 100644 --- a/src/textual/widgets/_collapsible.py +++ b/src/textual/widgets/_collapsible.py @@ -99,6 +99,42 @@ class Collapsible(Widget): } """ + class Toggled(Message): + """Parent class subclassed by `Collapsible` messages. + + Can be handled with `on(Collapsible.Toggled)` if you want to handle expansions + and collapsed in the same way, or you can handle the specific events individually. + """ + + def __init__(self, collapsible: Collapsible) -> None: + """Create an instance of the message. + + Args: + collapsible: The `Collapsible` widget that was toggled. + """ + self.collapsible: Collapsible = collapsible + """The collapsible that was toggled.""" + super().__init__() + + @property + def control(self) -> Collapsible: + """An alias for [Toggled.collapsible][textual.widgets.Collapsible.Toggled.collapsible].""" + return self.collapsible + + class Expanded(Toggled): + """Event sent when the `Collapsible` widget is expanded. + + Can be handled using `on_collapsible_expanded` in a subclass of + [`Collapsible`][textual.widgets.Collapsible] or in a parent widget in the DOM. + """ + + class Collapsed(Toggled): + """Event sent when the `Collapsible` widget is collapsed. + + Can be handled using `on_collapsible_collapsed` in a subclass of + [`Collapsible`][textual.widgets.Collapsible] or in a parent widget in the DOM. + """ + class Contents(Container): DEFAULT_CSS = """ Contents { @@ -143,9 +179,13 @@ def __init__( super().__init__(name=name, id=id, classes=classes, disabled=disabled) self.collapsed = collapsed - def on_collapsible_title_toggle(self, event: CollapsibleTitle.Toggle) -> None: + def _on_collapsible_title_toggle(self, event: CollapsibleTitle.Toggle) -> None: event.stop() self.collapsed = not self.collapsed + if self.collapsed: + self.post_message(self.Collapsed(self)) + else: + self.post_message(self.Expanded(self)) def _watch_collapsed(self, collapsed: bool) -> None: """Update collapsed state when reactive is changed.""" diff --git a/tests/test_collapsible.py b/tests/test_collapsible.py index db6f4c2147..2214d9fb9c 100644 --- a/tests/test_collapsible.py +++ b/tests/test_collapsible.py @@ -1,5 +1,6 @@ from __future__ import annotations +from textual import on from textual.app import App, ComposeResult from textual.widgets import Collapsible, Label from textual.widgets._collapsible import CollapsibleTitle @@ -115,3 +116,76 @@ def compose(self) -> ComposeResult: await pilot.click(CollapsibleTitle) assert not collapsible.collapsed + + +async def test_toggle_message(): + """Toggling should post a message.""" + + hits = [] + + class CollapsibleApp(App[None]): + def compose(self) -> ComposeResult: + yield Collapsible(collapsed=True) + + @on(Collapsible.Toggled) + def catch_collapsible_events(self) -> None: + hits.append("toggled") + + async with CollapsibleApp().run_test() as pilot: + assert pilot.app.query_one(Collapsible).collapsed + + await pilot.click(CollapsibleTitle) + await pilot.pause() + + assert not pilot.app.query_one(Collapsible).collapsed + assert len(hits) == 1 + + await pilot.click(CollapsibleTitle) + await pilot.pause() + + assert pilot.app.query_one(Collapsible).collapsed + assert len(hits) == 2 + + +async def test_expand_message(): + """Toggling should post a message.""" + + hits = [] + + class CollapsibleApp(App[None]): + def compose(self) -> ComposeResult: + yield Collapsible(collapsed=True) + + def on_collapsible_expanded(self) -> None: + hits.append("expanded") + + async with CollapsibleApp().run_test() as pilot: + assert pilot.app.query_one(Collapsible).collapsed + + await pilot.click(CollapsibleTitle) + await pilot.pause() + + assert not pilot.app.query_one(Collapsible).collapsed + assert len(hits) == 1 + + +async def test_collapse_message(): + """Toggling should post a message.""" + + hits = [] + + class CollapsibleApp(App[None]): + def compose(self) -> ComposeResult: + yield Collapsible(collapsed=False) + + def on_collapsible_collapsed(self) -> None: + hits.append("collapsed") + + async with CollapsibleApp().run_test() as pilot: + assert not pilot.app.query_one(Collapsible).collapsed + + await pilot.click(CollapsibleTitle) + await pilot.pause() + + assert pilot.app.query_one(Collapsible).collapsed + assert len(hits) == 1