From e48e0148b335e13d197c37e86112ad050fb4c113 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Tue, 29 Aug 2023 12:04:39 +0100 Subject: [PATCH 1/6] 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 --- src/textual/app.py | 2 +- src/textual/screen.py | 33 +++++++++++++++++++++++++++++++++ src/textual/widgets/_header.py | 10 +++++++++- 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 7e3a89c63..df7627c97 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -426,7 +426,7 @@ class App(Generic[ReturnType], DOMNode): 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 15efc9faf..df5c19b42 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -9,6 +9,7 @@ from functools import partial 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 @@ class Screen(Generic[ScreenResultType], Widget): ] 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 @@ class Screen(Generic[ScreenResultType], Widget): # 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 668105fa3..be36ba89e 100644 --- a/src/textual/widgets/_header.py +++ b/src/textual/widgets/_header.py @@ -161,11 +161,19 @@ class Header(Widget): 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) From 26e81c99e316b0b599c87dc2984c13414cc518b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Tue, 29 Aug 2023 12:06:59 +0100 Subject: [PATCH 2/6] Test screen (sub-)title. --- CHANGELOG.md | 5 ++ tests/test_header.py | 151 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 tests/test_header.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c463bd7c..31ef2bbd5 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/tests/test_header.py b/tests/test_header.py new file mode 100644 index 000000000..45df30fa2 --- /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" From c63072f5bd364550a93e4dade9b0c3e272102a2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Tue, 29 Aug 2023 12:08:57 +0100 Subject: [PATCH 3/6] Link App (sub-)title to Screen respectives. --- src/textual/app.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/textual/app.py b/src/textual/app.py index df7627c97..60f9103b6 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -308,13 +308,15 @@ class App(Generic[ReturnType], DOMNode): 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]] = [ From 4ed93d45c190f674605f1b32a8e0b036d7df6eb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Mon, 11 Sep 2023 10:27:24 +0100 Subject: [PATCH 4/6] Add screen_(sub_)title properties to header. Related review comment: https://github.com/Textualize/textual/pull/3199/files#r1321226453. --- CHANGELOG.md | 1 + src/textual/widgets/_header.py | 24 ++++++++++++++++-------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31ef2bbd5..36bb56975 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - `Screen.SUB_TITLE` - `Screen.title` - `Screen.sub_title` +- Properties `Header.screen_title` and `Header.screen_sub_title` https://github.com/Textualize/textual/pull/3199 ### Changed diff --git a/src/textual/widgets/_header.py b/src/textual/widgets/_header.py index be36ba89e..5a1155f14 100644 --- a/src/textual/widgets/_header.py +++ b/src/textual/widgets/_header.py @@ -160,18 +160,26 @@ class Header(Widget): def _on_click(self): self.toggle_class("-tall") + @property + def screen_title(self) -> str: + 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: + 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() -> 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 + self.query_one(HeaderTitle).text = self.screen_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.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) From 5ec3feafc7a1bd3a9c86c0f5049f89a5c23996b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Mon, 11 Sep 2023 10:28:50 +0100 Subject: [PATCH 5/6] Type Screen.(SUB_)TITLE as class var. Related review comment: https://github.com/Textualize/textual/pull/3199/files#r1321216368. --- src/textual/screen.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/screen.py b/src/textual/screen.py index df5c19b42..a999930c8 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -130,7 +130,7 @@ class Screen(Generic[ScreenResultType], Widget): } """ - TITLE: str | None = None + TITLE: ClassVar[str | None] = None """A class variable to set the *default* title for the screen. This overrides the app title. @@ -138,7 +138,7 @@ class Screen(Generic[ScreenResultType], Widget): you can set the [title][textual.screen.Screen.title] attribute. """ - SUB_TITLE: str | None = None + SUB_TITLE: ClassVar[str | None] = None """A class variable to set the *default* sub-title for the screen. This overrides the app sub-title. From 5a15e9c8aafa1e510170e173590ae335fba40549 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Mon, 11 Sep 2023 11:37:44 +0100 Subject: [PATCH 6/6] Add docstrings to properties. Related comment: https://github.com/Textualize/textual/pull/3199#discussion_r1321288977 --- src/textual/widgets/_header.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/textual/widgets/_header.py b/src/textual/widgets/_header.py index 5a1155f14..2c1bcaf32 100644 --- a/src/textual/widgets/_header.py +++ b/src/textual/widgets/_header.py @@ -162,12 +162,20 @@ class Header(Widget): @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