diff --git a/CHANGELOG.md b/CHANGELOG.md index 64fba1b744..d23d432b5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Widgets can now have an ALLOW_CHILDREN (bool) classvar to disallow adding children to a widget https://github.com/Textualize/textual/pull/3758 - Added the ability to set the `label` property of a `Checkbox` https://github.com/Textualize/textual/pull/3765 - Added the ability to set the `label` property of a `RadioButton` https://github.com/Textualize/textual/pull/3765 +- Added app focus/blur for textual-web https://github.com/Textualize/textual/pull/3767 ### Changed diff --git a/src/textual/app.py b/src/textual/app.py index f384aaaf3b..e76b96a847 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -367,6 +367,12 @@ class MyApp(App[None]): self.app.dark = not self.app.dark # Toggle dark mode ``` """ + app_focus = Reactive(True, compute=False) + """Indicates if the app has focus. + + When run in the terminal, the app always has focus. When run in the web, the app will + get focus when the terminal widget has focus. + """ def __init__( self, @@ -2672,6 +2678,8 @@ async def on_event(self, event: events.Event) -> None: await super().on_event(event) elif isinstance(event, events.InputEvent) and not event.is_forwarded: + if not self.app_focus and isinstance(event, (events.Key, events.MouseDown)): + self.app_focus = True if isinstance(event, events.MouseEvent): # Record current mouse position on App self.mouse_position = Offset(event.x, event.y) @@ -2819,6 +2827,16 @@ async def _on_resize(self, event: events.Resize) -> None: for screen in self._background_screens: screen.post_message(event) + async def _on_app_focus(self, event: events.AppFocus) -> None: + """App has focus.""" + # Required by textual-web to manage focus in a web page. + self.app_focus = True + + async def _on_app_blur(self, event: events.AppBlur) -> None: + """App has lost focus.""" + # Required by textual-web to manage focus in a web page. + self.app_focus = False + def _detach_from_dom(self, widgets: list[Widget]) -> list[Widget]: """Detach a list of widgets from the DOM. @@ -2976,6 +2994,15 @@ async def _prune_node(self, root: Widget) -> None: await root._close_messages(wait=True) self._unregister(root) + def _watch_app_focus(self, focus: bool) -> None: + """Respond to changes in app focus.""" + if focus: + focused = self.screen.focused + self.screen.set_focus(None) + self.screen.set_focus(focused) + else: + self.screen.set_focus(None) + async def action_check_bindings(self, key: str) -> None: """An [action](/guide/actions) to handle a key press using the binding system. diff --git a/src/textual/drivers/web_driver.py b/src/textual/drivers/web_driver.py index 58e3190145..535e3109c0 100644 --- a/src/textual/drivers/web_driver.py +++ b/src/textual/drivers/web_driver.py @@ -142,6 +142,7 @@ def do_exit() -> None: self._enable_bracketed_paste() self.flush() self._key_thread.start() + self._app.post_message(events.AppBlur()) def disable_input(self) -> None: """Disable further input.""" @@ -188,7 +189,7 @@ def _on_meta(self, packet_type: str, payload: bytes) -> None: payload: Meta payload (JSON encoded as bytes). """ payload_map = json.loads(payload) - _type = payload_map.get("type") + _type = payload_map.get("type", {}) if isinstance(payload_map, dict): self.on_meta(_type, payload_map) @@ -203,6 +204,10 @@ def on_meta(self, packet_type: str, payload: dict) -> None: self._size = (payload["width"], payload["height"]) size = Size(*self._size) self._app.post_message(events.Resize(size, size)) + elif packet_type == "focus": + self._app.post_message(events.AppFocus()) + elif packet_type == "blur": + self._app.post_message(events.AppBlur()) elif packet_type == "quit": self._app.post_message(messages.ExitApp()) elif packet_type == "exit": diff --git a/src/textual/events.py b/src/textual/events.py index 7cff7d01d0..01fec62ae6 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -561,6 +561,26 @@ class Blur(Event, bubble=False): """ +class AppFocus(Event, bubble=False): + """Sent when the app has focus. + + Used by textual-web. + + - [ ] Bubbles + - [ ] Verbose + """ + + +class AppBlur(Event, bubble=False): + """Sent when the app loses focus. + + Used by textual-web. + + - [ ] Bubbles + - [ ] Verbose + """ + + @dataclass class DescendantFocus(Event, bubble=True, verbose=True): """Sent when a child widget is focussed. diff --git a/src/textual/screen.py b/src/textual/screen.py index 25fc02404e..afe86ef3f3 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -819,12 +819,16 @@ def _on_screen_resume(self) -> None: size = self.app.size self._refresh_layout(size, full=True) self.refresh() - 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 + # Only auto-focus when the app has focus (textual-web only) + if self.app.app_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 def _on_screen_suspend(self) -> None: """Screen has suspended."""