Repository: Textualize/textual
Pull request: feat(collapsible): make title a reactive attribute
Closes #3829

**Please review the following checklist.**

- [ ] Docstrings on all new or modified functions / classes 
- [x] Updated documentation
- [x] Updated CHANGELOG.md (where appropriate)


Problem statement:
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()
~~~

Hints from issue 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!

Commit message: change collapsible title to static

Patch to review:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 625a57b202..a8ec8fe016 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 
 - 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
+- Added `Collapsible.title` reactive attribute https://github.com/Textualize/textual/pull/3830
 
 ## [0.44.1] - 2023-12-4
 
diff --git a/docs/widgets/collapsible.md b/docs/widgets/collapsible.md
index 417ce114ce..9ba79f8b47 100644
--- a/docs/widgets/collapsible.md
+++ b/docs/widgets/collapsible.md
@@ -122,9 +122,10 @@ The following example shows `Collapsible` widgets with custom expand/collapse sy
 
 ## Reactive Attributes
 
-| Name        | Type   | Default | Description                                          |
-| ----------- | ------ | ------- | ---------------------------------------------------- |
-| `collapsed` | `bool` | `True`  | Controls the collapsed/expanded state of the widget. |
+| Name        | Type   | Default     | Description                                          |
+| ----------- | ------ | ------------| ---------------------------------------------------- |
+| `collapsed` | `bool` | `True`      | Controls the collapsed/expanded state of the widget. |
+| `title`     | `str`  | `"Toggle"`  | Title of the collapsed/expanded contents.            |
 
 ## Messages
 
diff --git a/src/textual/widgets/_collapsible.py b/src/textual/widgets/_collapsible.py
index ba3565f20a..41c2b3c449 100644
--- 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,6 +178,8 @@ 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 = title
         self._title = CollapsibleTitle(
             label=title,
             collapsed_symbol=collapsed_symbol,
@@ -176,7 +187,6 @@ def __init__(
             collapsed=collapsed,
         )
         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,8 @@ 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:
+        if not self.is_mounted:
+            return
+        self._title.label = title
diff --git a/tests/test_collapsible.py b/tests/test_collapsible.py
index 2214d9fb9c..116ac74c9d 100644
--- 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"


Code review comments:
diff_hunk: @@ -214,3 +224,8 @@ 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:
+        if not self.is_mounted:
path: src/textual/widgets/_collapsible.py
text: @user1:
Do we need this? If somebody extends this class and modifies `title` in the constructor, won't this prevent the title from updating?

AFAIK we only need this if we are using the DOM, but updating the state is not a problem.

@author:
Here's the traceback if this is removed.

[EDIT: Argh, I'm clearly not on good form today. I just need to switch the order don't I...!]

```python
$ textual run docs/examples/widgets/collapsible.py 
╭──────────────────────────────────────────────────────────── Traceback (most recent call last) ─────────────────────────────────────────────────────────────╮
│ /home/tom/Projects/textual/docs/examples/widgets/collapsible.py:33 in compose                                                                              │
│                                                                                                                                                            │
│   30 │   def compose(self) -> ComposeResult:                                                                                                               │
│   31 │   │   """Compose app with collapsible containers."""                                                                                                │
│   32 │   │   yield Footer()                                                                                                                                │
│ ❱ 33 │   │   with Collapsible(collapsed=False, title="Leto"):                                                                                              │
│   34 │   │   │   yield Label(LETO)                                                                                                                         │
│   35 │   │   yield Collapsible(Markdown(JESSICA), collapsed=False, title="Jessica")                                                                        │
│   36 │   │   with Collapsible(collapsed=True, title="Paul"):                                                                                               │
│                                                                                                                                                            │
│ ╭─────────────────────────────── locals ────────────────────────────────╮                                                                                  │
│ │ self = CollapsibleApp(title='CollapsibleApp', classes={'-dark-mode'}) │                                                                                  │
│ ╰───────────────────────────────────────────────────────────────────────╯                                                                                  │
│                                                                                                                                                            │
│ /home/tom/Projects/textual/src/textual/widgets/_collapsible.py:182 in __init__                                                                             │
│                                                                                                                                                            │
│   179 │   │   │   disabled: Whether the collapsible is disabled or not.                        ╭───────────── locals ─────────────╮                        │
│   180 │   │   """                                                                              │         children = ()            │                        │
│   181 │   │   super().__init__(name=name, id=id, classes=classes, disabled=disabled)           │          classes = None          │                        │
│ ❱ 182 │   │   self.title = title                                                               │        collapsed = False         │                        │
│   183 │   │   self._title = CollapsibleTitle(                                                  │ collapsed_symbol = '▶'           │                        │
│   184 │   │   │   label=title,                                                                 │         disabled = False         │                        │
│   185 │   │   │   collapsed_symbol=collapsed_symbol,                                           │  expanded_symbol = '▼'           │                        │
│                                                                                                │               id = None          │                        │
│                                                                                                │             name = None          │                        │
│                                                                                                │             self = Collapsible() │                        │
│                                                                                                │            title = 'Leto'        │                        │
│                                                                                                ╰──────────────────────────────────╯                        │
│                                                                                                                                                            │
│ /home/tom/Projects/textual/src/textual/widgets/_collapsible.py:229 in _watch_title                                                                         │
│                                                                                                                                                            │
│   226 │   │   self._contents_list.append(widget)                                               ╭─────── locals ────────╮                                   │
│   227 │                                                                                        │  self = Collapsible() │                                   │
│   228 │   def _watch_title(self, title: str) -> None:                                          │ title = 'Toggle'      │                                   │
│ ❱ 229 │   │   self._title.label = title                                                        ╰───────────────────────╯                                   │
│   230                                                                                                                                                      │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
AttributeError: 'Collapsible' object has no attribute '_title'
```

@author:
I've now removed this so hopefully we're almost there.

Sorry I thought this was going to be a simple PR but I think my contributing has been more of a hindrance than help here. Thanks for your patience.

@user2:
It's not your fault. Your PR was in excellent shape when I first reviewed, I was the one that pushed you to do something else :P

@user1:

> Sorry I thought this was going to be a simple PR but I think my contributing has been more of a hindrance than help here. Thanks for your patience.

Not at all. You contributions are always welcome!