Repository: Textualize/textual
Pull request: Add title and sub-title to screens.
Mimicking 'App', we provide class variables TITLE and SUB_TITLE for the screen defaults and those can then be changed via the title and sub_title reactive attributes.

Related issue: #3195


Problem statement:
Screens should have a title and subtitle
When implementing a multi-screen application that has Header widgets in the screens the title and subtitle displayed in the Header should be stored in the Screen as overrides instead of only using the global values from the application.

This would simplify displaying titles in screens properly with keeping the title and subtitle synchronized with the screen stack.

Hints from issue discussion:
Should be easy to implement. Screen would have `title` and `subtitle`, which would override the App attributes if not None. We would need to manage titles when the active screen changes.

Commit message: Link App (sub-)title to Screen respectives.

Patch to review:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5c463bd7c8..31ef2bbd57 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 ### Added
 
 - TCSS styles `layer` and `layers` can be strings https://github.com/Textualize/textual/pull/3169
+- Screen-specific (sub-)title attributes https://github.com/Textualize/textual/pull/3199:
+  - `Screen.TITLE`
+  - `Screen.SUB_TITLE`
+  - `Screen.title`
+  - `Screen.sub_title`
 
 ### Changed
 
diff --git a/src/textual/app.py b/src/textual/app.py
index 7e3a89c634..60f9103b65 100644
--- a/src/textual/app.py
+++ b/src/textual/app.py
@@ -308,13 +308,15 @@ class MyApp(App[None]):
     TITLE: str | None = None
     """A class variable to set the *default* title for the application.
 
-    To update the title while the app is running, you can set the [title][textual.app.App.title] attribute
+    To update the title while the app is running, you can set the [title][textual.app.App.title] attribute.
+    See also [the `Screen.TITLE` attribute][textual.screen.Screen.TITLE].
     """
 
     SUB_TITLE: str | None = None
     """A class variable to set the default sub-title for the application.
 
     To update the sub-title while the app is running, you can set the [sub_title][textual.app.App.sub_title] attribute.
+    See also [the `Screen.SUB_TITLE` attribute][textual.screen.Screen.SUB_TITLE].
     """
 
     BINDINGS: ClassVar[list[BindingType]] = [
@@ -426,7 +428,7 @@ def __init__(
         an empty string if it doesn't.
 
         Sub-titles are typically used to show the high-level state of the app, such as the current mode, or path to
-        the file being worker on.
+        the file being worked on.
 
         Assign a new value to this attribute to change the sub-title.
         The new value is always converted to string.
diff --git a/src/textual/screen.py b/src/textual/screen.py
index 15efc9faf2..df5c19b42d 100644
--- a/src/textual/screen.py
+++ b/src/textual/screen.py
@@ -9,6 +9,7 @@
 from operator import attrgetter
 from typing import (
     TYPE_CHECKING,
+    Any,
     Awaitable,
     Callable,
     ClassVar,
@@ -128,10 +129,31 @@ class Screen(Generic[ScreenResultType], Widget):
         background: $surface;
     }
     """
+
+    TITLE: str | None = None
+    """A class variable to set the *default* title for the screen.
+
+    This overrides the app title.
+    To update the title while the screen is running,
+    you can set the [title][textual.screen.Screen.title] attribute.
+    """
+
+    SUB_TITLE: str | None = None
+    """A class variable to set the *default* sub-title for the screen.
+
+    This overrides the app sub-title.
+    To update the sub-title while the screen is running,
+    you can set the [sub_title][textual.screen.Screen.sub_title] attribute.
+    """
+
     focused: Reactive[Widget | None] = Reactive(None)
     """The focused [widget][textual.widget.Widget] or `None` for no focus."""
     stack_updates: Reactive[int] = Reactive(0, repaint=False)
     """An integer that updates when the screen is resumed."""
+    sub_title: Reactive[str | None] = Reactive(None, compute=False)
+    """Screen sub-title to override [the app sub-title][textual.app.App.sub_title]."""
+    title: Reactive[str | None] = Reactive(None, compute=False)
+    """Screen title to override [the app title][textual.app.App.title]."""
 
     BINDINGS = [
         Binding("tab", "focus_next", "Focus Next", show=False),
@@ -173,6 +195,9 @@ def __init__(
         ]
         self.css_path = css_paths
 
+        self.title = self.TITLE
+        self.sub_title = self.SUB_TITLE
+
     @property
     def is_modal(self) -> bool:
         """Is the screen modal?"""
@@ -1002,6 +1027,14 @@ def can_view(self, widget: Widget) -> bool:
         # Failing that fall back to normal checking.
         return super().can_view(widget)
 
+    def validate_title(self, title: Any) -> str | None:
+        """Ensure the title is a string or `None`."""
+        return None if title is None else str(title)
+
+    def validate_sub_title(self, sub_title: Any) -> str | None:
+        """Ensure the sub-title is a string or `None`."""
+        return None if sub_title is None else str(sub_title)
+
 
 @rich.repr.auto
 class ModalScreen(Screen[ScreenResultType]):
diff --git a/src/textual/widgets/_header.py b/src/textual/widgets/_header.py
index 668105fa31..be36ba89ef 100644
--- a/src/textual/widgets/_header.py
+++ b/src/textual/widgets/_header.py
@@ -161,11 +161,19 @@ def _on_click(self):
         self.toggle_class("-tall")
 
     def _on_mount(self, _: Mount) -> None:
-        def set_title(title: str) -> None:
+        def set_title() -> None:
+            screen_title = self.screen.title
+            title = screen_title if screen_title is not None else self.app.title
             self.query_one(HeaderTitle).text = title
 
         def set_sub_title(sub_title: str) -> None:
+            screen_sub_title = self.screen.sub_title
+            sub_title = (
+                screen_sub_title if screen_sub_title is not None else self.app.sub_title
+            )
             self.query_one(HeaderTitle).sub_text = sub_title
 
         self.watch(self.app, "title", set_title)
         self.watch(self.app, "sub_title", set_sub_title)
+        self.watch(self.screen, "title", set_title)
+        self.watch(self.screen, "sub_title", set_sub_title)
diff --git a/tests/test_header.py b/tests/test_header.py
new file mode 100644
index 0000000000..45df30fa20
--- /dev/null
+++ b/tests/test_header.py
@@ -0,0 +1,151 @@
+from textual.app import App
+from textual.screen import Screen
+from textual.widgets import Header
+
+
+async def test_screen_title_none_is_ignored():
+    class MyScreen(Screen):
+        def compose(self):
+            yield Header()
+
+    class MyApp(App):
+        TITLE = "app title"
+
+        def on_mount(self):
+            self.push_screen(MyScreen())
+
+    app = MyApp()
+    async with app.run_test():
+        assert app.query_one("HeaderTitle").text == "app title"
+
+
+async def test_screen_title_overrides_app_title():
+    class MyScreen(Screen):
+        TITLE = "screen title"
+
+        def compose(self):
+            yield Header()
+
+    class MyApp(App):
+        TITLE = "app title"
+
+        def on_mount(self):
+            self.push_screen(MyScreen())
+
+    app = MyApp()
+    async with app.run_test():
+        assert app.query_one("HeaderTitle").text == "screen title"
+
+
+async def test_screen_title_reactive_updates_title():
+    class MyScreen(Screen):
+        TITLE = "screen title"
+
+        def compose(self):
+            yield Header()
+
+    class MyApp(App):
+        TITLE = "app title"
+
+        def on_mount(self):
+            self.push_screen(MyScreen())
+
+    app = MyApp()
+    async with app.run_test() as pilot:
+        app.screen.title = "new screen title"
+        await pilot.pause()
+        assert app.query_one("HeaderTitle").text == "new screen title"
+
+
+async def test_app_title_reactive_does_not_update_title_when_screen_title_is_set():
+    class MyScreen(Screen):
+        TITLE = "screen title"
+
+        def compose(self):
+            yield Header()
+
+    class MyApp(App):
+        TITLE = "app title"
+
+        def on_mount(self):
+            self.push_screen(MyScreen())
+
+    app = MyApp()
+    async with app.run_test() as pilot:
+        app.title = "new app title"
+        await pilot.pause()
+        assert app.query_one("HeaderTitle").text == "screen title"
+
+
+async def test_screen_sub_title_none_is_ignored():
+    class MyScreen(Screen):
+        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():
+        assert app.query_one("HeaderTitle").sub_text == "app sub-title"
+
+
+async def test_screen_sub_title_overrides_app_sub_title():
+    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():
+        assert app.query_one("HeaderTitle").sub_text == "screen sub-title"
+
+
+async def test_screen_sub_title_reactive_updates_sub_title():
+    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.screen.sub_title = "new screen sub-title"
+        await pilot.pause()
+        assert app.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.query_one("HeaderTitle").sub_text == "screen sub-title"


Code review comments:
diff_hunk: @@ -161,11 +161,19 @@ def _on_click(self):
         self.toggle_class("-tall")
 
     def _on_mount(self, _: Mount) -> None:
-        def set_title(title: str) -> None:
+        def set_title() -> None:
+            screen_title = self.screen.title
path: src/textual/widgets/_header.py
text: @user1:
We should have a more elegant way of exposing the actual title. So that a developer doesn't need to replicate this logic.

Perhaps a computed value called `screen_title` and `screen_subtitle`

@author:
See 4ed93d45c

diff_hunk: @@ -128,10 +129,31 @@ class Screen(Generic[ScreenResultType], Widget):
         background: $surface;
     }
     """
+
+    TITLE: str | None = None
path: src/textual/screen.py
text: @user1:
These should be typed as `ClassVar`

@author:
See 5ec3feafc.