diff --git a/CHANGELOG.md b/CHANGELOG.md index 09838b667b..e94d86f9ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [6.5.1] - Unreleased + +### Added + +- Added `DOMNode.trap_focus` https://github.com/Textualize/textual/pull/6202 + ## [6.4.0] - 2025-10-22 ### Fixed diff --git a/src/textual/dom.py b/src/textual/dom.py index 7618016ded..b47b420f0e 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -228,6 +228,7 @@ def __init__( ) = None self._pruning = False self._query_one_cache: LRUCache[QueryOneCacheKey, DOMNode] = LRUCache(1024) + self._trap_focus = False super().__init__() @@ -475,6 +476,17 @@ def workers(self) -> WorkerManager: """The app's worker manager. Shortcut for `self.app.workers`.""" return self.app.workers + def trap_focus(self, trap_focus: bool = True) -> None: + """Trap the focus. + + When applied to a container, pressing tab to change focus will be limited to the container's + children if one of the children currently has focus. + + Args: + trap_focus: `True` to trap focus. `False` to restore default behavior. + """ + self._trap_focus = trap_focus + def run_worker( self, work: WorkType[ResultType], diff --git a/src/textual/screen.py b/src/textual/screen.py index 8d13ac67be..8af7c2f695 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -743,9 +743,18 @@ def focus_chain(self) -> list[Widget]: # Additionally, we manually keep track of the visibility of the DOM # instead of relying on the property `.visible` to save on DOM traversals. # node_stack: list[tuple[iterator over node children, node visibility]] + + root_node = self.screen + + if (focused := self.focused) is not None: + for node in focused.ancestors_with_self: + if node._trap_focus: + root_node = node + break + node_stack: list[tuple[Iterator[Widget], bool]] = [ ( - iter(sorted(self.displayed_children, key=focus_sorter)), + iter(sorted(root_node.displayed_children, key=focus_sorter)), self.visible, ) ] diff --git a/tests/test_focus.py b/tests/test_focus.py index 98698c3b42..fc4b1ce4cf 100644 --- a/tests/test_focus.py +++ b/tests/test_focus.py @@ -1,5 +1,5 @@ from textual.app import App, ComposeResult -from textual.containers import Container, ScrollableContainer +from textual.containers import Container, ScrollableContainer, Vertical from textual.widget import Widget from textual.widgets import Button, Label from textual.widgets._placeholder import Placeholder @@ -507,3 +507,44 @@ def compose(self) -> ComposeResult: assert isinstance(app.focused, Focusable) # Check focus chain assert app.screen.focus_chain == [app.query_one(Focusable)] + + +async def test_trap_focus(): + class TrapApp(App): + CSS = """ + Screen { + layout: horizontal; + } + """ + + def compose(self) -> ComposeResult: + with Vertical(id="left"): + yield Button("1", id="one") + yield Button("2", id="two") + with Vertical(id="right"): + yield Button("A", id="a") + yield Button("B", id="b") + + app = TrapApp() + async with app.run_test(): + # Normal focus chain reports all focusable widgets + focus_ids = [node.id for node in app.screen.focus_chain] + assert focus_ids == ["one", "two", "a", "b"] + + # Trap the focus on the left container + # Since Button#one is focused, the focus chain will be limited to the left vertical + app.screen.query_one("#left").trap_focus() + focus_ids = [node.id for node in app.screen.focus_chain] + assert focus_ids == ["one", "two"] + + # Trap focus on the right container + # Since the right container doesn't contain a focused widget, we would expect no change + app.screen.query_one("#right").trap_focus() + focus_ids = [node.id for node in app.screen.focus_chain] + assert focus_ids == ["one", "two"] + + # Un-trap the focus on the left container + # Should restore original focus chain + app.screen.query_one("#left").trap_focus(False) + focus_ids = [node.id for node in app.screen.focus_chain] + assert focus_ids == ["one", "two", "a", "b"]