Collapsible: title formatting is not updated unless title text actually changes
-
[x] Have you checked closed issues? (https://github.com/Textualize/textual/issues?q=is%3Aissue+is%3Aclosed)
-
[x] Have you checked against the most recent version of Textual? (https://pypi.org/search/?q=textual)
The bug
MRE
#!/usr/bin/env python3
from textual.app import App, ComposeResult
from textual.widgets import Label, Collapsible
INSTRUCTIONS = """[u]Instructions:[/u]
1. Wait for 2 seconds
2. The label inside the collapsible changes, this is good
3. The collapsible title remains unchanged, this is problematic
4. Wait for 2 seconds again
5. Both label and title change.
"""
COLLAPSIBLE_TITLE_1 = '[#ff0000]title[/]'
COLLAPSIBLE_TITLE_2 = '[#00ff00]title[/]' # color change
COLLAPSIBLE_TITLE_3 = '[#00ff00]TITLE[/]' # color and text change
class Textual3Collapsible(App):
def compose(self) -> ComposeResult:
yield Label(INSTRUCTIONS)
with Collapsible(title=COLLAPSIBLE_TITLE_1, collapsed=False):
yield Label(COLLAPSIBLE_TITLE_1)
def change(self, new_title) -> None:
self.query_one('Collapsible', Collapsible).title = new_title
self.query_one('Collapsible Label', Label).update(new_title)
def on_ready(self) -> None:
self.set_timer(2, self.change_1)
def change_1(self) -> None:
self.change(COLLAPSIBLE_TITLE_2)
self.set_timer(2, self.change_2)
def change_2(self) -> None:
self.change(COLLAPSIBLE_TITLE_3)
if __name__ == "__main__":
app = Textual3Collapsible()
app.run()
At t=0: ok:
At t=2: not ok, the collapsible title remains unchanged:
At t=4: ok, the collapsible title changed
Additionally, Collapsible's title reactive is a string whereas CollapsibleTitle's label reactive is a ContentText created using Content.from_text(). It would be nice if we could pass a ContentText to Collapsible without vexing mypy.
Alternatively, being able to import CollapsibleTitle would allow mypy to understand what is happening when working on a CollapsibleTitle widget obtained through query_one().
Textual Diagnostics
Versions
| Name | Value |
|---|---|
| Textual | 3.2.0 |
| Rich | 14.0.0 |
Python
| Name | Value |
|---|---|
| Version | 3.13.3 |
| Implementation | CPython |
| Compiler | GCC 14.2.0 |
| Executable | /path/to/my/venv/bin/python3 |
Operating System
| Name | Value |
|---|---|
| System | Linux |
| Release | 6.12.25-amd64 |
| Version | #1 SMP PREEMPT_DYNAMIC Debian 6.12.25-1 (2025-04-25) |
Terminal
| Name | Value |
|---|---|
| Terminal Application | tmux (3.5a) |
| TERM | tmux-256color |
| COLORTERM | truecolor |
| FORCE_COLOR | Not set |
| NO_COLOR | Not set |
Rich Console options
| Name | Value |
|---|---|
| size | width=106, height=57 |
| legacy_windows | False |
| min_width | 1 |
| max_width | 106 |
| is_terminal | False |
| encoding | utf-8 |
| max_height | 57 |
| justify | None |
| overflow | None |
| no_wrap | False |
| highlight | None |
| markup | None |
| height | None |
Thank you for your issue. Give us a little time to review it.
PS. You might want to check the FAQ if you haven't done so already.
This is an automated reply, generated by FAQtory
The problem is that Content objects are defined as equal if they have the same plain text, so because the reactive value hasn't changed the watch method is never called.
It looks like there's a similar problem in the Button (and maybe other widgets?) where the "smart refresh" isn't invoked when the new content only updates the style.
I'm not sure what the correct fix is here, perhaps these reactives need marking as always_update=True?
from textual.app import App, ComposeResult
from textual.widgets import Button, Static
CONTENT_PLAIN = "Update styles"
CONTENT_MARKUP = f"[magenta on yellow]{CONTENT_PLAIN}[/]"
class ExampleApp(App):
def compose(self) -> ComposeResult:
yield Button(CONTENT_PLAIN)
yield Static(CONTENT_PLAIN)
def on_button_pressed(self) -> None:
# This works...
self.query_one(Static).update(CONTENT_MARKUP)
# ...but this doesn't
self.query_one(Button).label = CONTENT_MARKUP
if __name__ == "__main__":
app = ExampleApp()
app.run()