Repository: Textualize/textual

Issue:
Make Title of a Collapsible Widget Editable
Can not update title property of the Collapsible widget , looking at CollapsibleTitle class it seems to only set at init
Looks like this was an intentional choice ?
But I think it would be helpful to make the title property editable ,
Eg: changing content in the collapsed state and don't want to toggle it open to get a quick status update, so write something in .title... 

~~~
from textual.app import App, ComposeResult
from textual.containers import Container, Horizontal, VerticalScroll
from textual.widgets import Header, Footer, Collapsible, Placeholder

class AnApp(App):

    TITLE = "Collapsible Titles Should Update"

    BINDINGS = [("d", "toggle_dark", "Update"),
    ("c", "collapse_or_expand(True)", "Collapse"),
    ("e", "collapse_or_expand(False)", "Expand")]
    
    def action_collapse_or_expand(self, collapse: bool) -> None:
        for child in self.walk_children(Collapsible):
            child.collapsed = collapse

    def compose(self) -> ComposeResult:
        """Create child widgets for the app."""
        yield Header(show_clock=True)
        yield VerticalScroll(
            Collapsible(
                Horizontal(
                 Placeholder(variant="text"),
                 Placeholder(variant="size"),
                 Placeholder(variant="text"),
            ),
            title="Top_Stuff",id="top"
            )
        )
        yield Footer()

    def action_toggle_dark(self) -> None:
        """An action to toggle dark mode."""
        top_collapsible_widget = self.query_one("#top")
        top_collapsible_widget.title = "UPDATED_Top_Stuff"
        self.app.title = "UPDATED_App_Title"
        self.dark = not self.dark


if __name__ == "__main__":
    app = AnApp()
    app.run()
~~~


Discussion:
Thank you for your issue. Give us a little time to review it.

PS. You might want to check the [FAQ](https://textual.textualize.io/FAQ/) if you haven't done so already.

This is an automated reply, generated by [FAQtory](https://github.com/willmcgugan/faqtory)
Not intentionally really. I can't think of a reason it shouldn't be editable.

Going to move this to ToDo as it seems an easy win. Would also accept a PR if you want to tackle it yourself!

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
@@ -1,8 +1,5 @@
 from __future__ import annotations
 
-from rich.console import RenderableType
-from rich.text import Text
-
 from .. import events
 from ..app import ComposeResult
 from ..binding import Binding
@@ -11,11 +8,12 @@
 from ..message import Message
 from ..reactive import reactive
 from ..widget import Widget
+from ..widgets import Static
 
 __all__ = ["Collapsible", "CollapsibleTitle"]
 
 
-class CollapsibleTitle(Widget, can_focus=True):
+class CollapsibleTitle(Static, can_focus=True):
     """Title and symbol for the Collapsible."""
 
     DEFAULT_CSS = """
@@ -44,6 +42,7 @@ class CollapsibleTitle(Widget, can_focus=True):
     """
 
     collapsed = reactive(True)
+    label = reactive("Toggle")
 
     def __init__(
         self,
@@ -57,7 +56,9 @@ def __init__(
         self.collapsed_symbol = collapsed_symbol
         self.expanded_symbol = expanded_symbol
         self.label = label
-        self.collapse = collapsed
+        self.collapsed = collapsed
+        self._collapsed_label = f"{collapsed_symbol} {label}"
+        self._expanded_label = f"{expanded_symbol} {label}"
 
     class Toggle(Message):
         """Request toggle."""
@@ -71,18 +72,26 @@ def action_toggle(self) -> None:
         """Toggle the state of the parent collapsible."""
         self.post_message(self.Toggle())
 
-    def render(self) -> RenderableType:
-        """Compose right/down arrow and label."""
+    def _watch_label(self, label: str) -> None:
+        self._collapsed_label = f"{self.collapsed_symbol} {label}"
+        self._expanded_label = f"{self.expanded_symbol} {label}"
         if self.collapsed:
-            return Text(f"{self.collapsed_symbol} {self.label}")
+            self.update(self._collapsed_label)
+        else:
+            self.update(self._expanded_label)
+
+    def _watch_collapsed(self, collapsed: bool) -> None:
+        if collapsed:
+            self.update(self._collapsed_label)
         else:
-            return Text(f"{self.expanded_symbol} {self.label}")
+            self.update(self._expanded_label)
 
 
 class Collapsible(Widget):
     """A collapsible container."""
 
     collapsed = reactive(True)
+    title = reactive("Toggle")
 
     DEFAULT_CSS = """
     Collapsible {
@@ -169,14 +178,15 @@ def __init__(
             classes: The CSS classes of the collapsible.
             disabled: Whether the collapsible is disabled or not.
         """
+        super().__init__(name=name, id=id, classes=classes, disabled=disabled)
         self._title = CollapsibleTitle(
             label=title,
             collapsed_symbol=collapsed_symbol,
             expanded_symbol=expanded_symbol,
             collapsed=collapsed,
         )
+        self.title = title
         self._contents_list: list[Widget] = list(children)
-        super().__init__(name=name, id=id, classes=classes, disabled=disabled)
         self.collapsed = collapsed
 
     def _on_collapsible_title_toggle(self, event: CollapsibleTitle.Toggle) -> None:
@@ -214,3 +224,6 @@ def compose_add_child(self, widget: Widget) -> None:
             widget: A Widget to add.
         """
         self._contents_list.append(widget)
+
+    def _watch_title(self, title: str) -> None:
+        self._title.label = title


Test patch:
diff --git a/tests/test_collapsible.py b/tests/test_collapsible.py
--- a/tests/test_collapsible.py
+++ b/tests/test_collapsible.py
@@ -189,3 +189,15 @@ def on_collapsible_collapsed(self) -> None:
 
         assert pilot.app.query_one(Collapsible).collapsed
         assert len(hits) == 1
+
+
+async def test_collapsible_title_reactive_change():
+    class CollapsibleApp(App[None]):
+        def compose(self) -> ComposeResult:
+            yield Collapsible(title="Old title")
+
+    async with CollapsibleApp().run_test() as pilot:
+        collapsible = pilot.app.query_one(Collapsible)
+        assert get_title(collapsible).label == "Old title"
+        collapsible.title = "New title"
+        assert get_title(collapsible).label == "New title"