Easier customization of Header title

This commit is contained in:
Will McGugan
2025-08-18 20:35:20 +01:00
parent 47d05079d7
commit d50a11bc03
5 changed files with 56 additions and 57 deletions

View File

@@ -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

View File

@@ -94,6 +94,7 @@ from textual.await_remove import AwaitRemove
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 @@ class App(Generic[ReturnType], DOMNode):
"""
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."""

View File

@@ -7,10 +7,12 @@ from datetime import datetime
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 @@ class HeaderClock(HeaderClockSpace):
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 @@ class Header(Widget):
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 @@ class Header(Widget):
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)

View File

@@ -25,7 +25,7 @@ class ScreenSplitApp(App[None]):
background: $panel;
}
Static {
#scroll1 Static, #scroll2 Static {
width: 1fr;
content-align: center middle;
background: $boost;

View File

@@ -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 @@ async def test_screen_title_none_is_ignored():
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 @@ async def test_screen_title_overrides_app_title():
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 @@ async def test_screen_title_reactive_updates_title():
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 @@ async def test_app_title_reactive_does_not_update_title_when_screen_title_is_set
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 @@ async def test_screen_sub_title_none_is_ignored():
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 @@ async def test_screen_sub_title_overrides_app_sub_title():
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 @@ async def test_screen_sub_title_reactive_updates_sub_title():
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"
)