Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](http://semver.org/).

## Unreleased

### Added

- Screen-specific (sub-)title attributes https://github.com/Textualize/textual/pull/3199:
- `Screen.TITLE`
- `Screen.SUB_TITLE`
- `Screen.title`
- `Screen.sub_title`
- Properties `Header.screen_title` and `Header.screen_sub_title` https://github.com/Textualize/textual/pull/3199

### Fixed

- Fixed a crash when removing an option from an `OptionList` while the mouse is hovering over the last option https://github.com/Textualize/textual/issues/3270
Expand Down
6 changes: 4 additions & 2 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]] = [
Expand Down Expand Up @@ -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.
Expand Down
33 changes: 33 additions & 0 deletions src/textual/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from operator import attrgetter
from typing import (
TYPE_CHECKING,
Any,
Awaitable,
Callable,
ClassVar,
Expand Down Expand Up @@ -128,10 +129,31 @@ class Screen(Generic[ScreenResultType], Widget):
background: $surface;
}
"""

TITLE: ClassVar[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: ClassVar[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),
Expand Down Expand Up @@ -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?"""
Expand Down Expand Up @@ -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]):
Expand Down
30 changes: 27 additions & 3 deletions src/textual/widgets/_header.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,12 +160,36 @@ def watch_tall(self, tall: bool) -> None:
def _on_click(self):
self.toggle_class("-tall")

@property
def screen_title(self) -> str:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docstrings pls.

Could we make these computed reactives? That way you could watch self.screen_title

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See 5a15e9c for docstrings.
As per our conversation, we can't make them computed reactives because we can't trigger the compute method when the reactives on Screen or App change.

"""The title that this header will display.

This depends on [`Screen.title`][textual.screen.Screen.title] and [`App.title`][textual.app.App.title].
"""
screen_title = self.screen.title
title = screen_title if screen_title is not None else self.app.title
return title

@property
def screen_sub_title(self) -> str:
"""The sub-title that this header will display.

This depends on [`Screen.sub_title`][textual.screen.Screen.sub_title] and [`App.sub_title`][textual.app.App.sub_title].
"""
screen_sub_title = self.screen.sub_title
sub_title = (
screen_sub_title if screen_sub_title is not None else self.app.sub_title
)
return sub_title

def _on_mount(self, _: Mount) -> None:
def set_title(title: str) -> None:
self.query_one(HeaderTitle).text = title
def set_title() -> None:
self.query_one(HeaderTitle).text = self.screen_title

def set_sub_title(sub_title: str) -> None:
self.query_one(HeaderTitle).sub_text = sub_title
self.query_one(HeaderTitle).sub_text = self.screen_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)
151 changes: 151 additions & 0 deletions tests/test_header.py
Original file line number Diff line number Diff line change
@@ -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"