Skip to content

Commit

Permalink
Merge pull request #3199 from Textualize/screen-title-sub-title
Browse files Browse the repository at this point in the history
Add title and sub-title to screens.
  • Loading branch information
rodrigogiraoserrao authored Sep 11, 2023
2 parents 9e29982 + 5a15e9c commit 7d4a47b
Show file tree
Hide file tree
Showing 5 changed files with 224 additions and 5 deletions.
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 @@ -312,13 +312,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 @@ -430,7 +432,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:
"""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"

0 comments on commit 7d4a47b

Please sign in to comment.