Repository: Textualize/textual

Issue:
AUTO_FOCUS on App should apply to screen
If you don't define a screen, there is no way to set `AUTO_FOCUS`. We should make `AUTO_FOCUS` on App apply to the default screen.

Discussion:

Patch:
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


Test patch:
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):