diff --git a/CHANGELOG.md b/CHANGELOG.md index 963e715032..3519cfbce8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,11 +20,13 @@ 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 https://github.com/Textualize/textual/pull/6051 - Added `Widget.get_line_filters` and `App.get_line_filters` https://github.com/Textualize/textual/pull/6057 ### 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 - Breaking change: Renamed `Label` constructor argument `renderable` to `content` for consistency https://github.com/Textualize/textual/pull/6045 - Breaking change: Optimization to line API to avoid applying background styles to widget content. In practice this means that you can no longer rely on blank Segments automatically getting the background color. diff --git a/src/textual/app.py b/src/textual/app.py index c3b6b5e9a4..8e250bf8d8 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 @@ -971,6 +972,27 @@ 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, + (" — ", "dim"), + sub_title_content.stylize("dim"), + ) + 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/__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/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/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()) 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" + )