Repository: Textualize/textual

Issue:
Add message `Collapsible.Toggled`
What it says on the tin.


Discussion:

Resolving patch:
diff --git a/src/textual/widgets/_collapsible.py b/src/textual/widgets/_collapsible.py
--- 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."""


Test patch:
diff --git a/tests/test_collapsible.py b/tests/test_collapsible.py
--- 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