From d50a11bc034c06995432645b1c97163a52afcb83 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 18 Aug 2025 20:35:20 +0100 Subject: [PATCH 1/4] Easier customization of Header title --- CHANGELOG.md | 1 + src/textual/app.py | 18 +++++++ src/textual/widgets/_header.py | 47 ++++++++----------- .../snapshot_apps/fr_with_min.py | 2 +- tests/test_header.py | 45 +++++++----------- 5 files changed, 56 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 124ba841d8..9620ce06ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `OptionList.set_options` https://github.com/Textualize/textual/pull/6048 - Added `TextArea.suggestion` https://github.com/Textualize/textual/pull/6048 - Added `TextArea.placeholder` https://github.com/Textualize/textual/pull/6048 +- Added `Header.format_title` and `App.format_title` for easier customization of title in the Header ### Changed diff --git a/src/textual/app.py b/src/textual/app.py index 310df9a07d..f61da133ef 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -94,6 +94,7 @@ from textual.binding import Binding, BindingsMap, BindingType, Keymap from textual.command import CommandListItem, CommandPalette, Provider, SimpleProvider from textual.compose import compose +from textual.content import Content from textual.css.errors import StylesheetError from textual.css.query import NoMatches from textual.css.stylesheet import RulesMap, Stylesheet @@ -963,6 +964,23 @@ def clipboard(self) -> str: """ return self._clipboard + def format_title(self, title: str, sub_title: str) -> Content: + """Format the title for display. + + Args: + title: The title. + sub_title: The sub title. + + Returns: + Content instance with title and subtitle. + """ + title_content = Content(title) + sub_title_content = Content(sub_title) + if sub_title_content: + return Content.assemble(title_content, " — ", sub_title_content) + else: + return title_content + @contextmanager def batch_update(self) -> Generator[None, None, None]: """A context manager to suspend all repaints until the end of the batch.""" diff --git a/src/textual/widgets/_header.py b/src/textual/widgets/_header.py index a760aa81d1..63a1a39278 100644 --- a/src/textual/widgets/_header.py +++ b/src/textual/widgets/_header.py @@ -7,10 +7,12 @@ from rich.text import Text from textual.app import RenderResult +from textual.content import Content from textual.dom import NoScreen from textual.events import Click, Mount from textual.reactive import Reactive from textual.widget import Widget +from textual.widgets import Static class HeaderIcon(Widget): @@ -98,34 +100,18 @@ def render(self) -> RenderResult: return Text(datetime.now().time().strftime(self.time_format)) -class HeaderTitle(Widget): +class HeaderTitle(Static): """Display the title / subtitle in the header.""" DEFAULT_CSS = """ HeaderTitle { + text-wrap: nowrap; + text-overflow: ellipsis; content-align: center middle; width: 100%; } """ - text: Reactive[str] = Reactive("") - """The main title text.""" - - sub_text = Reactive("") - """The sub-title text.""" - - def render(self) -> RenderResult: - """Render the title and sub-title. - - Returns: - The value to render. - """ - text = Text(self.text, no_wrap=True, overflow="ellipsis") - if self.sub_text: - text.append(" — ") - text.append(self.sub_text, "dim") - return text - class Header(Widget): """A header widget with icon and clock.""" @@ -196,6 +182,17 @@ def watch_tall(self, tall: bool) -> None: def _on_click(self): self.toggle_class("-tall") + def format_title(self) -> Content: + """Format the title and subtitle. + + Defers to [App.format_title][textual.app.App.format_title] by default. + Override this method if you want to customize how the title is displayed in the header. + + Returns: + Content for title display. + """ + return self.app.format_title(self.screen_title, self.screen_sub_title) + @property def screen_title(self) -> str: """The title that this header will display. @@ -221,17 +218,11 @@ def screen_sub_title(self) -> str: def _on_mount(self, _: Mount) -> None: async def set_title() -> None: try: - self.query_one(HeaderTitle).text = self.screen_title - except NoScreen: - pass - - async def set_sub_title() -> None: - try: - self.query_one(HeaderTitle).sub_text = self.screen_sub_title + self.query_one(HeaderTitle).update(self.format_title()) except NoScreen: pass self.watch(self.app, "title", set_title) - self.watch(self.app, "sub_title", set_sub_title) + self.watch(self.app, "sub_title", set_title) self.watch(self.screen, "title", set_title) - self.watch(self.screen, "sub_title", set_sub_title) + self.watch(self.screen, "sub_title", set_title) diff --git a/tests/snapshot_tests/snapshot_apps/fr_with_min.py b/tests/snapshot_tests/snapshot_apps/fr_with_min.py index 110cdd8b1e..21ab9492c9 100644 --- a/tests/snapshot_tests/snapshot_apps/fr_with_min.py +++ b/tests/snapshot_tests/snapshot_apps/fr_with_min.py @@ -25,7 +25,7 @@ class ScreenSplitApp(App[None]): background: $panel; } - Static { + #scroll1 Static, #scroll2 Static { width: 1fr; content-align: center middle; background: $boost; diff --git a/tests/test_header.py b/tests/test_header.py index 6ffdf0faad..a1873bf1c1 100644 --- a/tests/test_header.py +++ b/tests/test_header.py @@ -1,6 +1,6 @@ from textual.app import App from textual.screen import Screen -from textual.widgets import Header +from textual.widgets import Header, Static async def test_screen_title_none_is_ignored(): @@ -16,7 +16,7 @@ def on_mount(self): app = MyApp() async with app.run_test(): - assert app.screen.query_one("HeaderTitle").text == "app title" + assert app.screen.query_one("HeaderTitle", Static).content == "app title" async def test_screen_title_overrides_app_title(): @@ -34,7 +34,7 @@ def on_mount(self): app = MyApp() async with app.run_test(): - assert app.screen.query_one("HeaderTitle").text == "screen title" + assert app.screen.query_one("HeaderTitle", Static).content == "screen title" async def test_screen_title_reactive_updates_title(): @@ -54,7 +54,7 @@ def on_mount(self): async with app.run_test() as pilot: app.screen.title = "new screen title" await pilot.pause() - assert app.screen.query_one("HeaderTitle").text == "new screen title" + assert app.screen.query_one("HeaderTitle", Static).content == "new screen title" async def test_app_title_reactive_does_not_update_title_when_screen_title_is_set(): @@ -74,7 +74,7 @@ def on_mount(self): async with app.run_test() as pilot: app.title = "new app title" await pilot.pause() - assert app.screen.query_one("HeaderTitle").text == "screen title" + assert app.screen.query_one("HeaderTitle", Static).content == "screen title" async def test_screen_sub_title_none_is_ignored(): @@ -90,7 +90,10 @@ def on_mount(self): app = MyApp() async with app.run_test(): - assert app.screen.query_one("HeaderTitle").sub_text == "app sub-title" + assert ( + app.screen.query_one("HeaderTitle", Static).content + == "MyApp — app sub-title" + ) async def test_screen_sub_title_overrides_app_sub_title(): @@ -108,7 +111,10 @@ def on_mount(self): app = MyApp() async with app.run_test(): - assert app.screen.query_one("HeaderTitle").sub_text == "screen sub-title" + assert ( + app.screen.query_one("HeaderTitle", Static).content + == "MyApp — screen sub-title" + ) async def test_screen_sub_title_reactive_updates_sub_title(): @@ -128,24 +134,7 @@ def on_mount(self): async with app.run_test() as pilot: app.screen.sub_title = "new screen sub-title" await pilot.pause() - assert app.screen.query_one("HeaderTitle").sub_text == "new screen sub-title" - - -async def test_app_sub_title_reactive_does_not_update_sub_title_when_screen_sub_title_is_set(): - class MyScreen(Screen): - SUB_TITLE = "screen sub-title" - - def compose(self): - yield Header() - - class MyApp(App): - SUB_TITLE = "app sub-title" - - def on_mount(self): - self.push_screen(MyScreen()) - - app = MyApp() - async with app.run_test() as pilot: - app.sub_title = "new app sub-title" - await pilot.pause() - assert app.screen.query_one("HeaderTitle").sub_text == "screen sub-title" + assert ( + app.screen.query_one("HeaderTitle", Static).content + == "MyApp — new screen sub-title" + ) From 570899000d6908f2b788ad681e32bfe2ac48a8ec Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 18 Aug 2025 20:36:01 +0100 Subject: [PATCH 2/4] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9620ce06ca..9036b64712 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `OptionList.set_options` https://github.com/Textualize/textual/pull/6048 - Added `TextArea.suggestion` https://github.com/Textualize/textual/pull/6048 - Added `TextArea.placeholder` https://github.com/Textualize/textual/pull/6048 -- Added `Header.format_title` and `App.format_title` for easier customization of title in the Header +- Added `Header.format_title` and `App.format_title` for easier customization of title in the Header https://github.com/Textualize/textual/pull/6051 ### Changed From f90ac22e20cee1ee1e5997778a478a5adb00da11 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 18 Aug 2025 20:37:43 +0100 Subject: [PATCH 3/4] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9036b64712..42728c634d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Changed - Breaking change: The `renderable` property on the `Static` widget has been changed to `content`. https://github.com/Textualize/textual/pull/6041 +- Breaking change: `HeaderTitle` widget is now a static, with no `text` and `sub_text` reactives https://github.com/Textualize/textual/pull/6051 # [5.3.0] - 2025-08-07 From fef5f7afe686c88d6a8e1329aa98965c0463355c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 19 Aug 2025 08:49:12 +0100 Subject: [PATCH 4/4] test sub titles --- src/textual/app.py | 6 +- .../test_snapshots/test_header_format.svg | 151 ++++++++++++++++++ tests/snapshot_tests/test_snapshots.py | 16 ++ 3 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 tests/snapshot_tests/__snapshots__/test_snapshots/test_header_format.svg diff --git a/src/textual/app.py b/src/textual/app.py index f61da133ef..0ebcd75aa5 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -977,7 +977,11 @@ def format_title(self, title: str, sub_title: str) -> Content: title_content = Content(title) sub_title_content = Content(sub_title) if sub_title_content: - return Content.assemble(title_content, " — ", sub_title_content) + return Content.assemble( + title_content, + (" — ", "dim"), + sub_title_content.stylize("dim"), + ) else: return title_content diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_header_format.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_header_format.svg new file mode 100644 index 0000000000..c42c9adb6a --- /dev/null +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_header_format.svg @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Title + + + + + + + + + + Title — Sub-title + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 54ff172d67..c53fc803d8 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -4610,3 +4610,19 @@ def compose(self) -> ComposeResult: yield TextArea(placeholder="Your text here") assert snap_compare(TextApp()) + + +def test_header_format(snap_compare): + """Test title and sub-title are formatted as expected. + + You should see "Title - Sub-title" in the header. Where sub-title is dimmed. + """ + + class HeaderApp(App): + TITLE = "Title" + SUB_TITLE = "Sub-title" + + def compose(self) -> ComposeResult: + yield Header() + + assert snap_compare(HeaderApp())