diff --git a/CHANGELOG.md b/CHANGELOG.md index aeaaf466a..1e7cdebf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,10 @@ 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 +### Added + +- Class variable `AUTO_FOCUS` to screens https://github.com/Textualize/textual/issues/2457 + ## [0.24.1] - 2023-05-08 ### Fixed diff --git a/src/textual/screen.py b/src/textual/screen.py index 34db473ef..af0b006be 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -9,6 +9,7 @@ from typing import ( TYPE_CHECKING, Awaitable, Callable, + ClassVar, Generic, Iterable, Iterator, @@ -30,7 +31,7 @@ from ._types import CallbackType 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 @@ class ResultCallback(Generic[ScreenResultType]): 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 @@ class Screen(Generic[ScreenResultType], Widget): """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() diff --git a/tests/test_screens.py b/tests/test_screens.py index 6825c101c..2e3dbfcbe 100644 --- a/tests/test_screens.py +++ b/tests/test_screens.py @@ -6,6 +6,7 @@ import pytest 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"