Skip to content

Commit d50a11b

Browse files
committed
Easier customization of Header title
1 parent 47d0507 commit d50a11b

File tree

5 files changed

+56
-57
lines changed

5 files changed

+56
-57
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
2020
- Added `OptionList.set_options` https://github.com/Textualize/textual/pull/6048
2121
- Added `TextArea.suggestion` https://github.com/Textualize/textual/pull/6048
2222
- Added `TextArea.placeholder` https://github.com/Textualize/textual/pull/6048
23+
- Added `Header.format_title` and `App.format_title` for easier customization of title in the Header
2324

2425
### Changed
2526

src/textual/app.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@
9494
from textual.binding import Binding, BindingsMap, BindingType, Keymap
9595
from textual.command import CommandListItem, CommandPalette, Provider, SimpleProvider
9696
from textual.compose import compose
97+
from textual.content import Content
9798
from textual.css.errors import StylesheetError
9899
from textual.css.query import NoMatches
99100
from textual.css.stylesheet import RulesMap, Stylesheet
@@ -963,6 +964,23 @@ def clipboard(self) -> str:
963964
"""
964965
return self._clipboard
965966

967+
def format_title(self, title: str, sub_title: str) -> Content:
968+
"""Format the title for display.
969+
970+
Args:
971+
title: The title.
972+
sub_title: The sub title.
973+
974+
Returns:
975+
Content instance with title and subtitle.
976+
"""
977+
title_content = Content(title)
978+
sub_title_content = Content(sub_title)
979+
if sub_title_content:
980+
return Content.assemble(title_content, " — ", sub_title_content)
981+
else:
982+
return title_content
983+
966984
@contextmanager
967985
def batch_update(self) -> Generator[None, None, None]:
968986
"""A context manager to suspend all repaints until the end of the batch."""

src/textual/widgets/_header.py

Lines changed: 19 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
77
from rich.text import Text
88

99
from textual.app import RenderResult
10+
from textual.content import Content
1011
from textual.dom import NoScreen
1112
from textual.events import Click, Mount
1213
from textual.reactive import Reactive
1314
from textual.widget import Widget
15+
from textual.widgets import Static
1416

1517

1618
class HeaderIcon(Widget):
@@ -98,34 +100,18 @@ def render(self) -> RenderResult:
98100
return Text(datetime.now().time().strftime(self.time_format))
99101

100102

101-
class HeaderTitle(Widget):
103+
class HeaderTitle(Static):
102104
"""Display the title / subtitle in the header."""
103105

104106
DEFAULT_CSS = """
105107
HeaderTitle {
108+
text-wrap: nowrap;
109+
text-overflow: ellipsis;
106110
content-align: center middle;
107111
width: 100%;
108112
}
109113
"""
110114

111-
text: Reactive[str] = Reactive("")
112-
"""The main title text."""
113-
114-
sub_text = Reactive("")
115-
"""The sub-title text."""
116-
117-
def render(self) -> RenderResult:
118-
"""Render the title and sub-title.
119-
120-
Returns:
121-
The value to render.
122-
"""
123-
text = Text(self.text, no_wrap=True, overflow="ellipsis")
124-
if self.sub_text:
125-
text.append(" — ")
126-
text.append(self.sub_text, "dim")
127-
return text
128-
129115

130116
class Header(Widget):
131117
"""A header widget with icon and clock."""
@@ -196,6 +182,17 @@ def watch_tall(self, tall: bool) -> None:
196182
def _on_click(self):
197183
self.toggle_class("-tall")
198184

185+
def format_title(self) -> Content:
186+
"""Format the title and subtitle.
187+
188+
Defers to [App.format_title][textual.app.App.format_title] by default.
189+
Override this method if you want to customize how the title is displayed in the header.
190+
191+
Returns:
192+
Content for title display.
193+
"""
194+
return self.app.format_title(self.screen_title, self.screen_sub_title)
195+
199196
@property
200197
def screen_title(self) -> str:
201198
"""The title that this header will display.
@@ -221,17 +218,11 @@ def screen_sub_title(self) -> str:
221218
def _on_mount(self, _: Mount) -> None:
222219
async def set_title() -> None:
223220
try:
224-
self.query_one(HeaderTitle).text = self.screen_title
225-
except NoScreen:
226-
pass
227-
228-
async def set_sub_title() -> None:
229-
try:
230-
self.query_one(HeaderTitle).sub_text = self.screen_sub_title
221+
self.query_one(HeaderTitle).update(self.format_title())
231222
except NoScreen:
232223
pass
233224

234225
self.watch(self.app, "title", set_title)
235-
self.watch(self.app, "sub_title", set_sub_title)
226+
self.watch(self.app, "sub_title", set_title)
236227
self.watch(self.screen, "title", set_title)
237-
self.watch(self.screen, "sub_title", set_sub_title)
228+
self.watch(self.screen, "sub_title", set_title)

tests/snapshot_tests/snapshot_apps/fr_with_min.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ class ScreenSplitApp(App[None]):
2525
background: $panel;
2626
}
2727
28-
Static {
28+
#scroll1 Static, #scroll2 Static {
2929
width: 1fr;
3030
content-align: center middle;
3131
background: $boost;

tests/test_header.py

Lines changed: 17 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from textual.app import App
22
from textual.screen import Screen
3-
from textual.widgets import Header
3+
from textual.widgets import Header, Static
44

55

66
async def test_screen_title_none_is_ignored():
@@ -16,7 +16,7 @@ def on_mount(self):
1616

1717
app = MyApp()
1818
async with app.run_test():
19-
assert app.screen.query_one("HeaderTitle").text == "app title"
19+
assert app.screen.query_one("HeaderTitle", Static).content == "app title"
2020

2121

2222
async def test_screen_title_overrides_app_title():
@@ -34,7 +34,7 @@ def on_mount(self):
3434

3535
app = MyApp()
3636
async with app.run_test():
37-
assert app.screen.query_one("HeaderTitle").text == "screen title"
37+
assert app.screen.query_one("HeaderTitle", Static).content == "screen title"
3838

3939

4040
async def test_screen_title_reactive_updates_title():
@@ -54,7 +54,7 @@ def on_mount(self):
5454
async with app.run_test() as pilot:
5555
app.screen.title = "new screen title"
5656
await pilot.pause()
57-
assert app.screen.query_one("HeaderTitle").text == "new screen title"
57+
assert app.screen.query_one("HeaderTitle", Static).content == "new screen title"
5858

5959

6060
async def test_app_title_reactive_does_not_update_title_when_screen_title_is_set():
@@ -74,7 +74,7 @@ def on_mount(self):
7474
async with app.run_test() as pilot:
7575
app.title = "new app title"
7676
await pilot.pause()
77-
assert app.screen.query_one("HeaderTitle").text == "screen title"
77+
assert app.screen.query_one("HeaderTitle", Static).content == "screen title"
7878

7979

8080
async def test_screen_sub_title_none_is_ignored():
@@ -90,7 +90,10 @@ def on_mount(self):
9090

9191
app = MyApp()
9292
async with app.run_test():
93-
assert app.screen.query_one("HeaderTitle").sub_text == "app sub-title"
93+
assert (
94+
app.screen.query_one("HeaderTitle", Static).content
95+
== "MyApp — app sub-title"
96+
)
9497

9598

9699
async def test_screen_sub_title_overrides_app_sub_title():
@@ -108,7 +111,10 @@ def on_mount(self):
108111

109112
app = MyApp()
110113
async with app.run_test():
111-
assert app.screen.query_one("HeaderTitle").sub_text == "screen sub-title"
114+
assert (
115+
app.screen.query_one("HeaderTitle", Static).content
116+
== "MyApp — screen sub-title"
117+
)
112118

113119

114120
async def test_screen_sub_title_reactive_updates_sub_title():
@@ -128,24 +134,7 @@ def on_mount(self):
128134
async with app.run_test() as pilot:
129135
app.screen.sub_title = "new screen sub-title"
130136
await pilot.pause()
131-
assert app.screen.query_one("HeaderTitle").sub_text == "new screen sub-title"
132-
133-
134-
async def test_app_sub_title_reactive_does_not_update_sub_title_when_screen_sub_title_is_set():
135-
class MyScreen(Screen):
136-
SUB_TITLE = "screen sub-title"
137-
138-
def compose(self):
139-
yield Header()
140-
141-
class MyApp(App):
142-
SUB_TITLE = "app sub-title"
143-
144-
def on_mount(self):
145-
self.push_screen(MyScreen())
146-
147-
app = MyApp()
148-
async with app.run_test() as pilot:
149-
app.sub_title = "new app sub-title"
150-
await pilot.pause()
151-
assert app.screen.query_one("HeaderTitle").sub_text == "screen sub-title"
137+
assert (
138+
app.screen.query_one("HeaderTitle", Static).content
139+
== "MyApp — new screen sub-title"
140+
)

0 commit comments

Comments
 (0)