Repository: Textualize/textual

Issue:
Add built-in action handler for switching focus (e.g. `self.bind("f1", "focus(widget_id)")`)



Discussion:

Resolving patch:
diff --git a/sandbox/focus_keybindings.py b/sandbox/focus_keybindings.py
new file mode 100644
--- /dev/null
+++ b/sandbox/focus_keybindings.py
@@ -0,0 +1,54 @@
+from textual.app import App
+from textual.widget import Widget
+from textual.widgets import Static
+
+
+class FocusKeybindsApp(App):
+    dark = True
+
+    def on_load(self) -> None:
+        self.bind("1", "focus('widget1')")
+        self.bind("2", "focus('widget2')")
+        self.bind("3", "focus('widget3')")
+        self.bind("4", "focus('widget4')")
+        self.bind("q", "focus('widgetq')")
+        self.bind("w", "focus('widgetw')")
+        self.bind("e", "focus('widgete')")
+        self.bind("r", "focus('widgetr')")
+
+    def on_mount(self) -> None:
+        info = Static(
+            "Use keybinds to shift focus between the widgets in the lists below",
+        )
+        self.mount(info=info)
+
+        self.mount(
+            body=Widget(
+                Widget(
+                    Static("Press 1 to focus", id="widget1", classes="list-item"),
+                    Static("Press 2 to focus", id="widget2", classes="list-item"),
+                    Static("Press 3 to focus", id="widget3", classes="list-item"),
+                    Static("Press 4 to focus", id="widget4", classes="list-item"),
+                    classes="list",
+                    id="left_list",
+                ),
+                Widget(
+                    Static("Press Q to focus", id="widgetq", classes="list-item"),
+                    Static("Press W to focus", id="widgetw", classes="list-item"),
+                    Static("Press E to focus", id="widgete", classes="list-item"),
+                    Static("Press R to focus", id="widgetr", classes="list-item"),
+                    classes="list",
+                    id="right_list",
+                ),
+            ),
+        )
+        self.mount(footer=Static("No widget focused"))
+
+    def on_descendant_focus(self):
+        self.get_child("footer").update(
+            f"Focused: {self.focused.id}" or "No widget focused"
+        )
+
+
+app = FocusKeybindsApp(css_path="focus_keybindings.scss", watch_css=True)
+app.run()
diff --git a/src/textual/app.py b/src/textual/app.py
--- a/src/textual/app.py
+++ b/src/textual/app.py
@@ -48,6 +48,7 @@
 from ._event_broker import extract_handler_actions, NoHandler
 from .binding import Bindings, NoBinding
 from .css.stylesheet import Stylesheet
+from .css.query import NoMatchingNodesError
 from .design import ColorSystem
 from .devtools.client import DevtoolsClient, DevtoolsConnectionError, DevtoolsLog
 from .devtools.redirect_output import StdoutRedirector
@@ -1081,6 +1082,15 @@ async def action_bang(self) -> None:
     async def action_bell(self) -> None:
         self.bell()
 
+    async def action_focus(self, widget_id: str) -> None:
+        try:
+            node = self.query(f"#{widget_id}").first()
+        except NoMatchingNodesError:
+            pass
+        else:
+            if isinstance(node, Widget):
+                self.set_focus(node)
+
     async def action_add_class_(self, selector: str, class_name: str) -> None:
         self.screen.query(selector).add_class(class_name)
 
diff --git a/src/textual/widget.py b/src/textual/widget.py
--- a/src/textual/widget.py
+++ b/src/textual/widget.py
@@ -993,9 +993,21 @@ def on_blur(self, event: events.Blur) -> None:
 
     def on_descendant_focus(self, event: events.DescendantFocus) -> None:
         self.descendant_has_focus = True
+        if "focus-within" in self.pseudo_classes:
+            sender = event.sender
+            for child in self.walk_children(False):
+                child.refresh()
+                if child is sender:
+                    break
 
     def on_descendant_blur(self, event: events.DescendantBlur) -> None:
         self.descendant_has_focus = False
+        if "focus-within" in self.pseudo_classes:
+            sender = event.sender
+            for child in self.walk_children(False):
+                child.refresh()
+                if child is sender:
+                    break
 
     def on_mouse_scroll_down(self, event) -> None:
         if self.is_container:


Test patch: