From 20a2db934c3239b6ae082b7d4ff7997259019166 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 31 Oct 2025 10:27:04 +0000 Subject: [PATCH 1/6] trap focus --- src/textual/dom.py | 11 +++++++++++ src/textual/screen.py | 11 ++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index 7618016ded..5324a36176 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,16 @@ 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 restricted to that container. + + 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, ) ] From ec7425ccb4a339e1a71e1344ed260144b1cc5c81 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 31 Oct 2025 10:38:45 +0000 Subject: [PATCH 2/6] test for trap_focus --- CHANGELOG.md | 6 ++++++ tests/test_focus.py | 42 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09838b667b..f483a32f19 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` + ## [6.4.0] - 2025-10-22 ### Fixed diff --git a/tests/test_focus.py b/tests/test_focus.py index 98698c3b42..63783d120b 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,43 @@ 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(): + 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 containers 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"] + + # Untrap 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"] From 35cf222679873f2a32ab980630c0c2e3465eb0c5 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 31 Oct 2025 10:39:09 +0000 Subject: [PATCH 3/6] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f483a32f19..e94d86f9ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added -- Added `DOMNode.trap_focus` +- Added `DOMNode.trap_focus` https://github.com/Textualize/textual/pull/6202 ## [6.4.0] - 2025-10-22 From 5470245e6fb8082d344bda0e78b7e6d14a3c9ed2 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 31 Oct 2025 10:55:34 +0000 Subject: [PATCH 4/6] Update tests/test_focus.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/test_focus.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_focus.py b/tests/test_focus.py index 63783d120b..101a159d3b 100644 --- a/tests/test_focus.py +++ b/tests/test_focus.py @@ -537,7 +537,7 @@ def compose(self) -> ComposeResult: assert focus_ids == ["one", "two"] # Trap focus on the right container - # Since the right containers doesn't contain a focused widget, we would expect no change + # 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"] From 3588c9c61546373aecf71ad1d083a500beac2acb Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 31 Oct 2025 10:57:42 +0000 Subject: [PATCH 5/6] docstring --- src/textual/dom.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index 5324a36176..b47b420f0e 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -479,7 +479,8 @@ def workers(self) -> WorkerManager: def trap_focus(self, trap_focus: bool = True) -> None: """Trap the focus. - When applied to a container, pressing tab to change focus will be restricted to that container. + 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. From 6fbc1d1edc0ecac4fdbeb1df71e20c61eea6bed1 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 31 Oct 2025 11:26:44 +0000 Subject: [PATCH 6/6] comments --- tests/test_focus.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_focus.py b/tests/test_focus.py index 101a159d3b..fc4b1ce4cf 100644 --- a/tests/test_focus.py +++ b/tests/test_focus.py @@ -527,6 +527,7 @@ def compose(self) -> ComposeResult: 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"] @@ -542,7 +543,7 @@ def compose(self) -> ComposeResult: focus_ids = [node.id for node in app.screen.focus_chain] assert focus_ids == ["one", "two"] - # Untrap the focus on the left container + # 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]