diff --git a/CHANGELOG.md b/CHANGELOG.md index 7859d9034b..24410aa105 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - Mapping of ANSI colors to hex codes configurable via `App.ansi_theme_dark` and `App.ansi_theme_light` https://github.com/Textualize/textual/pull/4192 +- `Pilot.resize_terminal` to resize the terminal in testing https://github.com/Textualize/textual/issues/4212 ### Fixed @@ -26,9 +27,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Rename `CollapsibleTitle.action_toggle` to `action_toggle_collapsible` to fix clash with `DOMNode.action_toggle` https://github.com/Textualize/textual/pull/4221 - Markdown component classes weren't refreshed when watching for CSS https://github.com/Textualize/textual/issues/3464 -### Added +### Changed -- `Pilot.resize_terminal` to resize the terminal in testing https://github.com/Textualize/textual/issues/4212 +- Clicking a non focusable widget focus ancestors https://github.com/Textualize/textual/pull/4236 ## [0.52.1] - 2024-02-20 diff --git a/src/textual/screen.py b/src/textual/screen.py index 99d65ddf2d..f9674a472f 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -307,6 +307,29 @@ def get_widgets_at(self, x: int, y: int) -> Iterable[tuple[Widget, Region]]: """ return self._compositor.get_widgets_at(x, y) + def get_focusable_widget_at(self, x: int, y: int) -> Widget | None: + """Get the focusable widget under a given coordinate. + + If the widget directly under the given coordinate is not focusable, then this method will check + if any of the ancestors are focusable. If no ancestors are focusable, then `None` will be returned. + + Args: + x: X coordinate. + y: Y coordinate. + + Returns: + A `Widget`, or `None` if there is no focusable widget underneath the coordinate. + """ + try: + widget, _region = self.get_widget_at(x, y) + except NoWidget: + return None + + for node in widget.ancestors_with_self: + if isinstance(node, Widget) and node.focusable: + return node + return None + def get_style_at(self, x: int, y: int) -> Style: """Get the style under a given coordinate. @@ -1015,8 +1038,10 @@ def _forward_event(self, event: events.Event) -> None: except errors.NoWidget: self.set_focus(None) else: - if isinstance(event, events.MouseDown) and widget.focusable: - self.set_focus(widget, scroll_visible=False) + if isinstance(event, events.MouseDown): + focusable_widget = self.get_focusable_widget_at(event.x, event.y) + if focusable_widget: + self.set_focus(focusable_widget, scroll_visible=False) event.style = self.get_style_at(event.screen_x, event.screen_y) if widget is self: event._set_forwarded() diff --git a/tests/test_focus.py b/tests/test_focus.py index 67b35d0a93..0942753a63 100644 --- a/tests/test_focus.py +++ b/tests/test_focus.py @@ -1,10 +1,10 @@ import pytest from textual.app import App, ComposeResult -from textual.containers import Container +from textual.containers import Container, ScrollableContainer from textual.screen import Screen from textual.widget import Widget -from textual.widgets import Button +from textual.widgets import Button, Label class Focusable(Widget, can_focus=True): @@ -409,3 +409,42 @@ def compose(self) -> ComposeResult: classes = list(button.get_pseudo_classes()) assert "blur" not in classes assert "focus" in classes + + +async def test_get_focusable_widget_at() -> None: + """Check that clicking a non-focusable widget will focus any (focusable) ancestors.""" + + class FocusApp(App): + AUTO_FOCUS = None + + def compose(self) -> ComposeResult: + with ScrollableContainer(id="focusable"): + with Container(): + yield Label("Foo", id="foo") + yield Label("Bar", id="bar") + yield Label("Egg", id="egg") + + app = FocusApp() + async with app.run_test() as pilot: + # Nothing focused + assert app.screen.focused is None + # Click foo + await pilot.click("#foo") + # Confirm container is focused + assert app.screen.focused is not None + assert app.screen.focused.id == "focusable" + # Reset focus + app.screen.set_focus(None) + assert app.screen.focused is None + # Click bar + await pilot.click("#bar") + # Confirm container is focused + assert app.screen.focused is not None + assert app.screen.focused.id == "focusable" + # Reset focus + app.screen.set_focus(None) + assert app.screen.focused is None + # Click egg (outside of focusable widget) + await pilot.click("#egg") + # Confirm nothing focused + assert app.screen.focused is None