Repository: Textualize/textual
Pull request: AUTO_FOCUS targets first focusable widget.
Fixes #2578.


Problem statement:
AUTO_FOCUS has no effect in most cases
Testing with modal02.py in /docs/examples/guide/screens AUTO_FOCUS doesn't appear to work.

Looking at the code, there doesn't appear to be any check that the thing being auto focused is actually focusable. Suspect it is trying to focus some unrelated widget.

Hints from issue discussion:
@rodrigogiraoserrao one for you I think
So we should go over the query results and focus the first thing that is matched by the query and that is focusable, right?
That would be the idea?
Exactly.
AH
_This_ was why `AUTO_FOCUS` didn't break tests before.
Yeah, makes sense now.

We could take the easy route of making `AUTO_FOCUS` default to `None`
Or we just fix the tests. Yeah, lets just fix the tests.
> Or we just fix the tests. Yeah, lets just fix the tests.

I'm already fixing the tests o7

Commit message: Merge branch 'main' into auto-focus-improv

Patch to review:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bce29efdac..957c586e80 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 - Fixed `TreeNode.toggle` and `TreeNode.toggle_all` not posting a `Tree.NodeExpanded` or `Tree.NodeCollapsed` message https://github.com/Textualize/textual/issues/2535
 - `footer--description` component class was being ignored https://github.com/Textualize/textual/issues/2544
 - Pasting empty selection in `Input` would raise an exception https://github.com/Textualize/textual/issues/2563
+- `Screen.AUTO_FOCUS` now focuses the first _focusable_ widget that matches the selector https://github.com/Textualize/textual/issues/2578
 
 ### Added
 
diff --git a/src/textual/screen.py b/src/textual/screen.py
index 09505cc535..8528aeecc9 100644
--- a/src/textual/screen.py
+++ b/src/textual/screen.py
@@ -666,15 +666,18 @@ def _on_screen_resume(self) -> None:
         """Screen has resumed."""
         self.stack_updates += 1
         size = self.app.size
+        self._refresh_layout(size, full=True)
+        self.refresh()
         if self.AUTO_FOCUS is not None and self.focused is None:
             try:
-                to_focus = self.query(self.AUTO_FOCUS).first()
+                focus_candidates = self.query(self.AUTO_FOCUS)
             except NoMatches:
                 pass
             else:
-                self.set_focus(to_focus)
-        self._refresh_layout(size, full=True)
-        self.refresh()
+                for widget in focus_candidates:
+                    if widget.focusable:
+                        self.set_focus(widget)
+                        break
 
     def _on_screen_suspend(self) -> None:
         """Screen has suspended."""
diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py
index ea5e321538..d70fc761e3 100644
--- a/tests/snapshot_tests/test_snapshots.py
+++ b/tests/snapshot_tests/test_snapshots.py
@@ -203,9 +203,11 @@ def test_option_list(snap_compare):
     assert snap_compare(WIDGET_EXAMPLES_DIR / "option_list_options.py")
     assert snap_compare(WIDGET_EXAMPLES_DIR / "option_list_tables.py")
 
+
 def test_option_list_build(snap_compare):
     assert snap_compare(SNAPSHOT_APPS_DIR / "option_list.py")
 
+
 def test_progress_bar_indeterminate(snap_compare):
     assert snap_compare(WIDGET_EXAMPLES_DIR / "progress_bar_isolated_.py", press=["f"])
 
@@ -440,7 +442,7 @@ def test_modal_dialog_bindings_input(snap_compare):
     # Check https://github.com/Textualize/textual/issues/2194
     assert snap_compare(
         SNAPSHOT_APPS_DIR / "modal_screen_bindings.py",
-        press=["enter", "tab", "h", "!", "left", "i", "tab"],
+        press=["enter", "h", "!", "left", "i", "tab"],
     )
 
 
diff --git a/tests/test_screens.py b/tests/test_screens.py
index 5b29b1dd5c..bd9dfba3ce 100644
--- a/tests/test_screens.py
+++ b/tests/test_screens.py
@@ -6,7 +6,7 @@
 
 from textual.app import App, ScreenStackError
 from textual.screen import Screen
-from textual.widgets import Button, Input
+from textual.widgets import Button, Input, Label
 
 skip_py310 = pytest.mark.skipif(
     sys.version_info.minor == 10 and sys.version_info.major == 3,
@@ -155,8 +155,7 @@ async def test_screens():
 
 async def test_auto_focus():
     class MyScreen(Screen[None]):
-        def compose(self) -> None:
-            print("composing")
+        def compose(self):
             yield Button()
             yield Input(id="one")
             yield Input(id="two")
@@ -194,6 +193,22 @@ class MyApp(App[None]):
         assert app.focused.id == "two"
 
 
+async def test_auto_focus_skips_non_focusable_widgets():
+    class MyScreen(Screen[None]):
+        def compose(self):
+            yield Label()
+            yield Button()
+
+    class MyApp(App[None]):
+        def on_mount(self):
+            self.push_screen(MyScreen())
+
+    app = MyApp()
+    async with app.run_test():
+        assert app.focused is not None
+        assert isinstance(app.focused, Button)
+
+
 async def test_dismiss_non_top_screen():
     class MyApp(App[None]):
         async def key_p(self) -> None:


Code review comments:
diff_hunk: @@ -666,15 +666,18 @@ def _on_screen_resume(self) -> None:
         """Screen has resumed."""
         self.stack_updates += 1
         size = self.app.size
+        self._refresh_layout(size, full=True)
+        self.refresh()
         if self.AUTO_FOCUS is not None and self.focused is None:
             try:
-                to_focus = self.query(self.AUTO_FOCUS).first()
+                focus_candidates = self.query(self.AUTO_FOCUS)
path: src/textual/screen.py
text: @user1:
Since you aren't using `first()` now, this will never raise `NoMatches`

@author:
Thanks!