textual icon indicating copy to clipboard operation
textual copied to clipboard

Collapsible: title formatting is not updated unless title text actually changes

Open xavierog opened this issue 9 months ago • 2 comments

  • [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: Image

At t=2: not ok, the collapsible title remains unchanged: Image

At t=4: ok, the collapsible title changed Image

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

xavierog avatar May 04 '25 09:05 xavierog

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

github-actions[bot] avatar May 04 '25 09:05 github-actions[bot]

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()

TomJGooding avatar May 07 '25 18:05 TomJGooding