From bc92cf57e789f14580dcc03e0bd5de67806418cd 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, 23 May 2023 11:32:40 +0100 Subject: [PATCH] Add auto focus to app. Related issues: #2594. --- CHANGELOG.md | 3 ++ src/textual/app.py | 8 +++++ src/textual/screen.py | 10 +++--- tests/test_screens.py | 81 +++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 96 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3085106dd..41bfd46c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,15 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - `work` decorator accepts `description` parameter to add debug string https://github.com/Textualize/textual/issues/2597 +- `App.AUTO_FOCUS` to set auto focus on all screens https://github.com/Textualize/textual/issues/2594 ### Changed - `Placeholder` now sets its color cycle per app https://github.com/Textualize/textual/issues/2590 - Footer now clears key highlight regardless of whether it's in the active screen or not https://github.com/Textualize/textual/issues/2606 - The default Widget repr no longer displays classes and pseudo-classes (to reduce noise in logs). Add them to your `__rich_repr__` method if needed. https://github.com/Textualize/textual/pull/2623 +- Setting `Screen.AUTO_FOCUS` to `None` will inherit `AUTO_FOCUS` from the app instead of disabling it https://github.com/Textualize/textual/issues/2594 +- Setting `Screen.AUTO_FOCUS` to `""` will disable it on the screen https://github.com/Textualize/textual/issues/2594 ### Removed diff --git a/src/textual/app.py b/src/textual/app.py index aca3be101..a17811cb1 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -275,6 +275,14 @@ class App(Generic[ReturnType], DOMNode): """ SCREENS: ClassVar[dict[str, Screen | Callable[[], Screen]]] = {} """Screens associated with the app for the lifetime of the app.""" + + AUTO_FOCUS: ClassVar[str | None] = "*" + """A selector to determine what to focus automatically when a screen is activated. + + The widget focused is the first that matches the given [CSS selector](/guide/queries/#query-selectors). + Setting to `None` or `""` disables auto focus. + """ + _BASE_PATH: str | None = None CSS_PATH: ClassVar[CSSPathType | None] = None """File paths to load CSS from.""" diff --git a/src/textual/screen.py b/src/textual/screen.py index 1d04c0602..33f44c7ea 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -94,11 +94,12 @@ class ResultCallback(Generic[ScreenResultType]): class Screen(Generic[ScreenResultType], Widget): """The base class for screens.""" - AUTO_FOCUS: ClassVar[str | None] = "*" + AUTO_FOCUS: ClassVar[str | None] = None """A selector to determine what to focus automatically when the screen is activated. The widget focused is the first that matches the given [CSS selector](/guide/queries/#query-selectors). - Set to `None` to disable auto focus. + Set to `None` to inherit the value from the screen's app. + Set to `""` to disable auto focus. """ DEFAULT_CSS = """ @@ -680,8 +681,9 @@ class Screen(Generic[ScreenResultType], Widget): size = self.app.size self._refresh_layout(size, full=True) self.refresh() - if self.AUTO_FOCUS is not None and self.focused is None: - for widget in self.query(self.AUTO_FOCUS): + auto_focus = self.app.AUTO_FOCUS if self.AUTO_FOCUS is None else self.AUTO_FOCUS + if auto_focus and self.focused is None: + for widget in self.query(auto_focus): if widget.focusable: self.set_focus(widget) break diff --git a/tests/test_screens.py b/tests/test_screens.py index bd9dfba3c..033891990 100644 --- a/tests/test_screens.py +++ b/tests/test_screens.py @@ -153,7 +153,9 @@ async def test_screens(): await app._shutdown() -async def test_auto_focus(): +async def test_auto_focus_on_screen_if_app_auto_focus_is_none(): + """Setting app.AUTO_FOCUS = `None` means it is not taken into consideration.""" + class MyScreen(Screen[None]): def compose(self): yield Button() @@ -161,10 +163,11 @@ async def test_auto_focus(): yield Input(id="two") class MyApp(App[None]): - pass + AUTO_FOCUS = None app = MyApp() async with app.run_test(): + MyScreen.AUTO_FOCUS = "*" await app.push_screen(MyScreen()) assert isinstance(app.focused, Button) app.pop_screen() @@ -193,6 +196,80 @@ async def test_auto_focus(): assert app.focused.id == "two" +async def test_auto_focus_on_screen_if_app_auto_focus_is_disabled(): + """Setting app.AUTO_FOCUS = `None` means it is not taken into consideration.""" + + class MyScreen(Screen[None]): + def compose(self): + yield Button() + yield Input(id="one") + yield Input(id="two") + + class MyApp(App[None]): + AUTO_FOCUS = "" + + app = MyApp() + async with app.run_test(): + MyScreen.AUTO_FOCUS = "*" + await app.push_screen(MyScreen()) + assert isinstance(app.focused, Button) + app.pop_screen() + + MyScreen.AUTO_FOCUS = None + await app.push_screen(MyScreen()) + assert app.focused is None + app.pop_screen() + + MyScreen.AUTO_FOCUS = "Input" + await app.push_screen(MyScreen()) + assert isinstance(app.focused, Input) + assert app.focused.id == "one" + app.pop_screen() + + MyScreen.AUTO_FOCUS = "#two" + await app.push_screen(MyScreen()) + assert isinstance(app.focused, Input) + assert app.focused.id == "two" + + # If we push and pop another screen, focus should be preserved for #two. + MyScreen.AUTO_FOCUS = None + await app.push_screen(MyScreen()) + assert app.focused is None + app.pop_screen() + assert app.focused.id == "two" + + +async def test_auto_focus_inheritance(): + """Setting app.AUTO_FOCUS = `None` means it is not taken into consideration.""" + + class MyScreen(Screen[None]): + def compose(self): + yield Button() + yield Input(id="one") + yield Input(id="two") + + class MyApp(App[None]): + pass + + app = MyApp() + async with app.run_test(): + MyApp.AUTO_FOCUS = "Input" + MyScreen.AUTO_FOCUS = "*" + await app.push_screen(MyScreen()) + assert isinstance(app.focused, Button) + app.pop_screen() + + MyScreen.AUTO_FOCUS = None + await app.push_screen(MyScreen()) + assert isinstance(app.focused, Input) + app.pop_screen() + + MyScreen.AUTO_FOCUS = "" + await app.push_screen(MyScreen()) + assert app.focused is None + app.pop_screen() + + async def test_auto_focus_skips_non_focusable_widgets(): class MyScreen(Screen[None]): def compose(self):