Add auto focus to app.

Related issues: #2594.
This commit is contained in:
Rodrigo Girão Serrão
2023-05-23 11:32:40 +01:00
parent 607939e41d
commit bc92cf57e7
4 changed files with 96 additions and 6 deletions

View File

@@ -10,12 +10,15 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Added ### Added
- `work` decorator accepts `description` parameter to add debug string https://github.com/Textualize/textual/issues/2597 - `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 ### Changed
- `Placeholder` now sets its color cycle per app https://github.com/Textualize/textual/issues/2590 - `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 - 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 - 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 ### Removed

View File

@@ -275,6 +275,14 @@ class App(Generic[ReturnType], DOMNode):
""" """
SCREENS: ClassVar[dict[str, Screen | Callable[[], Screen]]] = {} SCREENS: ClassVar[dict[str, Screen | Callable[[], Screen]]] = {}
"""Screens associated with the app for the lifetime of the app.""" """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 _BASE_PATH: str | None = None
CSS_PATH: ClassVar[CSSPathType | None] = None CSS_PATH: ClassVar[CSSPathType | None] = None
"""File paths to load CSS from.""" """File paths to load CSS from."""

View File

@@ -94,11 +94,12 @@ class ResultCallback(Generic[ScreenResultType]):
class Screen(Generic[ScreenResultType], Widget): class Screen(Generic[ScreenResultType], Widget):
"""The base class for screens.""" """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. """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). 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 = """ DEFAULT_CSS = """
@@ -680,8 +681,9 @@ class Screen(Generic[ScreenResultType], Widget):
size = self.app.size size = self.app.size
self._refresh_layout(size, full=True) self._refresh_layout(size, full=True)
self.refresh() self.refresh()
if self.AUTO_FOCUS is not None and self.focused is None: auto_focus = self.app.AUTO_FOCUS if self.AUTO_FOCUS is None else self.AUTO_FOCUS
for widget in self.query(self.AUTO_FOCUS): if auto_focus and self.focused is None:
for widget in self.query(auto_focus):
if widget.focusable: if widget.focusable:
self.set_focus(widget) self.set_focus(widget)
break break

View File

@@ -153,7 +153,9 @@ async def test_screens():
await app._shutdown() 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]): class MyScreen(Screen[None]):
def compose(self): def compose(self):
yield Button() yield Button()
@@ -161,10 +163,11 @@ async def test_auto_focus():
yield Input(id="two") yield Input(id="two")
class MyApp(App[None]): class MyApp(App[None]):
pass AUTO_FOCUS = None
app = MyApp() app = MyApp()
async with app.run_test(): async with app.run_test():
MyScreen.AUTO_FOCUS = "*"
await app.push_screen(MyScreen()) await app.push_screen(MyScreen())
assert isinstance(app.focused, Button) assert isinstance(app.focused, Button)
app.pop_screen() app.pop_screen()
@@ -193,6 +196,80 @@ async def test_auto_focus():
assert app.focused.id == "two" 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(): async def test_auto_focus_skips_non_focusable_widgets():
class MyScreen(Screen[None]): class MyScreen(Screen[None]):
def compose(self): def compose(self):