Repository: Textualize/textual

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

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cd28386fd..ddd8fb60d 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
 
@@ -36,6 +37,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
  - `link-hover-background` renamed to `link-background-hover`
  - `link-hover-color` renamed to `link-color-hover`
  - `link-hover-style` renamed to `link-style-hover`
+- `Tree` now forces a scroll when `scroll_to_node` is called https://github.com/Textualize/textual/pull/3786
 - Brought rxvt's use of shift-numpad keys in line with most other terminals https://github.com/Textualize/textual/pull/3769
 
 ### Added
diff --git a/docs/widgets/collapsible.md b/docs/widgets/collapsible.md
index 009f7f760..417ce114c 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 5901cbc9d..dcd9441e4 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 @@ class Collapsible(Widget):
         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/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py
index c95fb9c79..5a4ca1f9b 100644
--- a/src/textual/widgets/_tree.py
+++ b/src/textual/widgets/_tree.py
@@ -904,7 +904,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
         """
         region = self._get_label_region(line)
         if region is not None:
-            self.scroll_to_region(region, animate=animate)
+            self.scroll_to_region(region, animate=animate, force=True)
 
     def scroll_to_node(
         self, node: TreeNode[TreeDataType], animate: bool = True


Test patch:
diff --git a/tests/test_collapsible.py b/tests/test_collapsible.py
index db6f4c214..2214d9fb9 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 @@ async def test_toggle_title():
 
         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