From bb1d15ef456c77a8f2bed0c8650617728cd4a5d8 Mon Sep 17 00:00:00 2001 From: Jimmy Date: Mon, 29 Jun 2026 13:47:19 -0400 Subject: [PATCH] Cache focus_chain and invalidate on layout, focus, and trap_focus changes Recalculating the focus chain on every access traverses the entire DOM sorted by focus order, which is moderately expensive. Cache the result and clear it in the three places that make it stale: _on_layout (mount/unmount/ visibility/disabled changes), set_focus (root node depends on focused widget when _trap_focus is active), and trap_focus() (directly changes which node acts as root). --- src/textual/dom.py | 8 ++++++++ src/textual/screen.py | 10 ++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index 44e1396e36..473c50a3b8 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -492,6 +492,10 @@ def trap_focus(self, trap_focus: bool = True) -> None: trap_focus: `True` to trap focus. `False` to restore default behavior. """ self._trap_focus = trap_focus + try: + self.screen._focus_chain_cache = None + except Exception: + pass def run_worker( self, @@ -1328,6 +1332,10 @@ def _add_children(self, *nodes: Widget) -> None: node._attach(self) _append(node) node._add_children(*node._pending_children) + try: + self.screen._focus_chain_cache = None + except Exception: + pass WalkType = TypeVar("WalkType", bound="DOMNode") diff --git a/src/textual/screen.py b/src/textual/screen.py index af9070538e..c6cf667e71 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -326,6 +326,9 @@ def __init__( self._css_update_count = -1 """Track updates to CSS.""" + self._focus_chain_cache: list[Widget] | None = None + """Cached focus chain, invalidated on layout or focus change.""" + self._layout_widgets: dict[DOMNode, set[Widget]] = {} """Widgets whose layout may have changed.""" @@ -771,8 +774,8 @@ def _select_all_in_widget(self, widget: Widget) -> None: @property def focus_chain(self) -> list[Widget]: """A list of widgets that may receive focus, in focus order.""" - # TODO: Calculating a focus chain is moderately expensive. - # Suspect we can move focus without calculating the entire thing again. + if self._focus_chain_cache is not None: + return self._focus_chain_cache widgets: list[Widget] = [] add_widget = widgets.append @@ -823,6 +826,7 @@ def focus_chain(self) -> list[Widget]: if node_is_visible and node.allow_focus(): add_widget(node) + self._focus_chain_cache = widgets return widgets def _move_focus( @@ -1113,6 +1117,7 @@ def set_focus( # Widget is already focused return + self._focus_chain_cache = None focused: Widget | None = None blurred: Widget | None = None @@ -1423,6 +1428,7 @@ async def _on_layout(self, message: messages.Layout) -> None: break widget = ancestor + self._focus_chain_cache = None if layout_required and not self._layout_required: self._layout_required = True self.check_idle()