Repository: Textualize/textual

Issue:
Add option to auto focus the first thing on a screen.
I'm thinking that screen could grow a `auto_focus` parameter which takes a selector. It would then focus the first matching node, if there is one.

This would need to be done when the screen is first made active, and when it is resumed. i.e. if a screen is popped off the stack, the new one should focus.

`auto_focus` should be `"*"` by default to focus the first focusable thing. `auto_focus` may be set to `None` to disable auto focus.


Discussion:
For the case where there's a stack with 2+ screens, when we pop a screen and we go back to a previous screen, I think we should preserve the widget that was already focused previously.

Resolving patch:
diff --git a/src/textual/screen.py b/src/textual/screen.py
--- a/src/textual/screen.py
+++ b/src/textual/screen.py
@@ -9,6 +9,7 @@
     TYPE_CHECKING,
     Awaitable,
     Callable,
+    ClassVar,
     Generic,
     Iterable,
     Iterator,
@@ -30,7 +31,7 @@
 from .binding import Binding
 from .css.match import match
 from .css.parse import parse_selectors
-from .css.query import QueryType
+from .css.query import NoMatches, QueryType
 from .dom import DOMNode
 from .geometry import Offset, Region, Size
 from .reactive import Reactive
@@ -93,6 +94,13 @@ def __call__(self, result: ScreenResultType) -> None:
 class Screen(Generic[ScreenResultType], Widget):
     """The base class for screens."""
 
+    AUTO_FOCUS: ClassVar[str | 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.
+    """
+
     DEFAULT_CSS = """
     Screen {
         layout: vertical;
@@ -100,7 +108,6 @@ class Screen(Generic[ScreenResultType], Widget):
         background: $surface;
     }
     """
-
     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)
@@ -659,6 +666,13 @@ def _on_screen_resume(self) -> None:
         """Screen has resumed."""
         self.stack_updates += 1
         size = self.app.size
+        if self.AUTO_FOCUS is not None and self.focused is None:
+            try:
+                to_focus = self.query(self.AUTO_FOCUS).first()
+            except NoMatches:
+                pass
+            else:
+                self.set_focus(to_focus)
         self._refresh_layout(size, full=True)
         self.refresh()
 


Test patch:
diff --git a/tests/test_screens.py b/tests/test_screens.py
--- a/tests/test_screens.py
+++ b/tests/test_screens.py
@@ -6,6 +6,7 @@
 
 from textual.app import App, ScreenStackError
 from textual.screen import Screen
+from textual.widgets import Button, Input
 
 skip_py310 = pytest.mark.skipif(
     sys.version_info.minor == 10 and sys.version_info.major == 3,
@@ -150,3 +151,44 @@ async def test_screens():
     screen2.remove()
     screen3.remove()
     await app._shutdown()
+
+
+async def test_auto_focus():
+    class MyScreen(Screen[None]):
+        def compose(self) -> None:
+            print("composing")
+            yield Button()
+            yield Input(id="one")
+            yield Input(id="two")
+
+    class MyApp(App[None]):
+        pass
+
+    app = MyApp()
+    async with app.run_test():
+        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"