From 6fa47f230094655edfe7791620f5b78db7b6e963 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 17:19:18 -0400 Subject: [PATCH 1/4] Derive native command tables from mode types, not mounted editors --- src/textual/binding.py | 7 ++++ src/textual/document/_editing.py | 26 +++++++++----- src/textual/dom.py | 5 +++ src/textual/screen.py | 6 ++++ src/textual/widgets/_footer.py | 58 +++++++++++++++++++------------ src/textual/widgets/_text_area.py | 10 ++++-- 6 files changed, 80 insertions(+), 32 deletions(-) diff --git a/src/textual/binding.py b/src/textual/binding.py index 985f0af4fe..363062c0cb 100644 --- a/src/textual/binding.py +++ b/src/textual/binding.py @@ -10,6 +10,7 @@ import dataclasses from dataclasses import dataclass, field from inspect import iscoroutinefunction +from functools import lru_cache from types import FunctionType from typing import ( TYPE_CHECKING, @@ -442,6 +443,12 @@ def __init__( for binding in Binding.make_bindings(bindings or {}): self.key_to_bindings.setdefault(binding.key, []).append(binding) + @classmethod + @lru_cache(maxsize=None) + def editor_commands(cls, owner: type[EditingMode], mode: type[EditingMode]) -> BindingsMap: + """Command declarations depend on the mode types, never a mounted editor.""" + return cls(owner.command_bindings(mode)) + def _add_binding(self, binding: Binding) -> None: """Add a new binding. diff --git a/src/textual/document/_editing.py b/src/textual/document/_editing.py index 1f7780213e..9cf780429a 100644 --- a/src/textual/document/_editing.py +++ b/src/textual/document/_editing.py @@ -9,7 +9,7 @@ from dataclasses import dataclass, replace from typing import TYPE_CHECKING, ClassVar -from textual.binding import Binding, CommandBinding, MethodBinding +from textual.binding import Binding, BindingsMap, CommandBinding, MethodBinding from textual.document._document import Location, Selection from textual._clipboard import ClipboardContent, LinewiseClipboard from textual.keys import KeyDeclaration @@ -58,7 +58,8 @@ def apply_native_binding(self, editor: TextArea, binding: MethodBinding, event: finally: editor.editing_mode = editor.editing_mode.settled() - def command_bindings(self) -> tuple[Binding, ...]: + @classmethod + def command_bindings(cls, mode: type[EditingMode]) -> tuple[Binding, ...]: return () @classmethod @@ -116,12 +117,15 @@ def _handle_key(self, editor: TextArea, event: Key) -> bool: def pasted(self) -> EditingMode: return VimInsert() - def command_bindings(self) -> tuple[Binding, ...]: + @classmethod + def command_bindings(cls, mode: type[EditingMode]) -> tuple[Binding, ...]: return tuple(binding for command in EditorCommand.declarations() - for binding in self.declared_command_bindings(command)) + for binding in cls.declared_command_bindings(command, mode)) - def declared_command_bindings(self, command: type[EditorCommand]) -> tuple[Binding, ...]: - return command.bindings_for(self.binding_mode) + @classmethod + def declared_command_bindings(cls, command: type[EditorCommand], + mode: type[EditingMode]) -> tuple[Binding, ...]: + return command.bindings_for(mode) def apply_command(self, editor: TextArea, command: type[EditorCommand]) -> bool: command.apply_mode(editor, self) @@ -319,8 +323,10 @@ def document_changed(self) -> EditingMode: def selection_range(self, editor: TextArea) -> Selection: return self.previous.selection_range(editor) - def declared_command_bindings(self, command: type[EditorCommand]) -> tuple[Binding, ...]: - return command.pending_bindings(self.binding_mode) + @classmethod + def declared_command_bindings(cls, command: type[EditorCommand], + mode: type[EditingMode]) -> tuple[Binding, ...]: + return command.pending_bindings(mode) def _handle_key(self, editor: TextArea, event: Key) -> bool: if editor.edit_key(event): @@ -343,6 +349,10 @@ def edit(self, editor: DOMNode, event: Key) -> bool: class EditorCommand(KeyDeclaration["TextArea"]): keys: ClassVar[tuple[str, ...]] = () + + def __init_subclass__(cls, **kwargs) -> None: + super().__init_subclass__(**kwargs) + BindingsMap.editor_commands.cache_clear() _declared: ClassVar[list[type[EditorCommand]]] = [] def __init_subclass__(cls, **kwargs) -> None: diff --git a/src/textual/dom.py b/src/textual/dom.py index f2ccbc9e96..2e0778f673 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -150,6 +150,11 @@ def key_mode(self) -> type[EditingMode]: """Ordinary controls use their screen's declared keyboard scope.""" return self.screen.key_mode + @property + def key_mode_label(self) -> str: + """Display the current keyboard scope without interpreting its identity.""" + return self.key_mode().label + @property def binding_mode(self) -> type[EditingMode]: """The canonical editing mode used to project this node's bindings.""" diff --git a/src/textual/screen.py b/src/textual/screen.py index 71c3da882a..a004f742e5 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -155,6 +155,12 @@ def key_mode(self) -> type[EditingMode]: """Screens start with ordinary keys; an application scope can select a profile.""" return EditingMode + @property + def active_key_mode_label(self) -> str: + """Project the focused owner's mode, or this screen's keyboard scope.""" + focused = self.focused + return focused.key_mode_label if focused is not None else self.key_mode_label + @property def active_binding_mode(self) -> type[EditingMode]: """Every scope uses the focused editor's actual mode for this binding chain.""" diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index ca315c76b8..79a5894523 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -40,6 +40,7 @@ class FooterBindingGroup: class FooterProjection: groups: tuple[FooterBindingGroup, ...] palette: FooterBinding | None + mode_label: str columns: int @@ -281,6 +282,9 @@ def compose(self) -> ComposeResult: else: for item in part.bindings: yield self._make_key(item) + mode_label = FooterLabel(projection.mode_label, classes="-mode") + mode_label.display = bool(projection.mode_label) + yield mode_label if projection.palette is not None: yield self._make_key(projection.palette, palette=True) @@ -337,7 +341,10 @@ def _project_bindings(self) -> FooterProjection: palette_display = FooterBinding( binding, enabled, tooltip, self.app.get_key_display(binding) ) - return FooterProjection(tuple(groups), palette_display, len(by_action)) + mode_label = self.screen.active_key_mode_label + return FooterProjection( + tuple(groups), palette_display, mode_label, len(by_action) + bool(mode_label) + ) async def _reconcile_bindings(self) -> None: revision = self._binding_revision @@ -376,28 +383,35 @@ async def _apply_binding_projection(self) -> None: if not same_groups: await self.recompose() return - parents = iter(self.children) - for part in projection.groups: - if part.group is not None: - parent = next(parents) - assert isinstance(parent, KeyGroup) - await self._reconcile_keys(parent, part.bindings, grouped=True) - next(parents) # The unchanged group's description label. - else: - # A flat footer is the common case; grouped/flat structural - # transitions retain ordinary recomposition above. - if len(projection.groups) != 1: - await self.recompose() + if previous is None or ( + previous.groups != projection.groups or previous.palette != projection.palette + ): + parents = iter(self.children) + for part in projection.groups: + if part.group is not None: + parent = next(parents) + assert isinstance(parent, KeyGroup) + await self._reconcile_keys(parent, part.bindings, grouped=True) + next(parents) # The unchanged group's description label. + else: + # A flat footer is the common case; grouped/flat structural + # transitions retain ordinary recomposition above. + if len(projection.groups) != 1: + await self.recompose() + return + await self._reconcile_keys(self, part.bindings) + if not self.is_attached or self._closing or self._pruning: return - await self._reconcile_keys(self, part.bindings) - if not self.is_attached or self._closing or self._pruning: - return - if projection.palette is not None: - self._update_key( - self.query_one(".-command-palette", FooterKey), - projection.palette, - palette=True, - ) + if projection.palette is not None: + self._update_key( + self.query_one(".-command-palette", FooterKey), + projection.palette, + palette=True, + ) + mode_label = self.query_one(".-mode", FooterLabel) + if previous is None or previous.mode_label != projection.mode_label: + mode_label.update(projection.mode_label) + mode_label.display = bool(projection.mode_label) self.styles.grid_size_columns = projection.columns self._binding_projection = projection diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 9a9280762c..75212b1c9f 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -145,18 +145,24 @@ def key_mode(self) -> type[EditingMode]: """Editor bindings follow the live editor state, including native transitions.""" return type(self.editing_mode) + @property + def key_mode_label(self) -> str: + """Read the actual state, including parameterized pending modes.""" + return self.editing_mode.label + @property def binding_mode(self) -> type[EditingMode]: return self.editing_mode.binding_mode def binding_map(self, mode: type[EditingMode]) -> BindingsMap: return BindingsMap.merge(( - BindingsMap(self.editing_mode.command_bindings()), super().binding_map(mode), + BindingsMap.editor_commands(type(self.editing_mode), self.binding_mode), + super().binding_map(mode), )) @classmethod def editor_command_bindings(cls, mode: type[EditingMode]) -> tuple[Binding, ...]: - return mode().command_bindings() + return mode.command_bindings(mode) def apply_editor_binding(self, binding: MethodBinding, event: events.Key) -> bool: return self.editing_mode.apply_native_binding(self, binding, event) From ad356fbe5fcc3c9b7e2f560c247168b48db8ee10 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 17:42:49 -0400 Subject: [PATCH 2/4] Reconcile footer groups and move common keys without recomposing --- src/textual/widgets/_footer.py | 162 +++++++++++++++++++-------------- 1 file changed, 94 insertions(+), 68 deletions(-) diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index 79a5894523..b36ff03340 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -32,9 +32,37 @@ class FooterBinding: @dataclass(frozen=True) class FooterBindingGroup: - group: Binding.Group | None bindings: tuple[FooterBinding, ...] + @property + def description(self) -> str: + return "" + + @property + def compact(self) -> bool: + return False + + @property + def grouped(self) -> bool: + return False + + +@dataclass(frozen=True) +class FooterGroupedBindings(FooterBindingGroup): + group: Binding.Group + + @property + def description(self) -> str: + return self.group.description + + @property + def compact(self) -> bool: + return self.group.compact + + @property + def grouped(self) -> bool: + return True + @dataclass(frozen=True) class FooterProjection: @@ -48,10 +76,27 @@ class FooterProjection: class KeyGroup(HorizontalGroup): DEFAULT_CSS = """ KeyGroup { - width: auto; + width: auto; + height: 1; } """ + def __init__(self, part: FooterBindingGroup, *keys: FooterKey) -> None: + key_group = HorizontalGroup(*keys, classes="binding-group") + key_group.set_class(part.compact, "-compact", update=False) + label = FooterLabel(part.description) + label.display = bool(part.description) + super().__init__(key_group, label) + + def update_projection(self, part: FooterBindingGroup) -> HorizontalGroup: + keys = self.query_one(".binding-group", HorizontalGroup) + keys.set_class(part.compact, "-compact") + label = self.query_one(FooterLabel) + if label.content != part.description: + label.update(part.description) + label.display = bool(part.description) + return keys + @rich.repr.auto class FooterKey(Widget): @@ -203,7 +248,7 @@ class Footer(ScrollableContainer, can_focus=False, can_focus_children=False): width: auto; height: 1; } - KeyGroup.-compact { + KeyGroup .binding-group.-compact { FooterKey.-grouped { margin: 0; } @@ -226,8 +271,6 @@ class Footer(ScrollableContainer, can_focus=False, can_focus_children=False): compact = reactive(False, toggle_class="-compact") """Display in compact style.""" - _bindings_ready = reactive(False, repaint=False) - """True if the bindings are ready to be displayed.""" show_command_palette = reactive(True) """Show the key to invoke the command palette.""" combine_groups = reactive(True) @@ -264,24 +307,16 @@ def __init__( self.set_reactive(Footer.show_command_palette, show_command_palette) self.set_reactive(Footer.compact, compact) self.set_class(compact, "-compact", update=False) - self._binding_projection: FooterProjection | None = None + self._binding_projection = FooterProjection((), None, "", 0) self._binding_update_pending = False self._binding_revision = 0 def compose(self) -> ComposeResult: - if not self._bindings_ready: - return - projection = self._binding_projection = self._project_bindings() + projection = self._binding_projection self.styles.grid_size_columns = projection.columns for part in projection.groups: - if part.group is not None: - with KeyGroup(classes="-compact" if part.group.compact else ""): - for item in part.bindings: - yield self._make_key(item, grouped=True) - yield FooterLabel(part.group.description) - else: - for item in part.bindings: - yield self._make_key(item) + yield KeyGroup(part, *(self._make_key(item, grouped=part.grouped) + for item in part.bindings)) mode_label = FooterLabel(projection.mode_label, classes="-mode") mode_label.display = bool(projection.mode_label) yield mode_label @@ -307,7 +342,6 @@ def _make_key( return key def bindings_changed(self, screen: Screen) -> None: - self._bindings_ready = True self._binding_revision += 1 if not screen.app.app_focus: return @@ -327,9 +361,10 @@ def _project_bindings(self) -> FooterProjection: groups = [] for group, items in groupby(by_action.values(), lambda item: item.binding.group): bindings = tuple(items) - groups.append(FooterBindingGroup( - group if group is not None and len(bindings) > 1 else None, bindings - )) + groups.append( + FooterGroupedBindings(bindings, group) + if group is not None and len(bindings) > 1 else FooterBindingGroup(bindings) + ) palette = active.get(self.app.COMMAND_PALETTE_BINDING) palette_display = None if ( @@ -371,45 +406,37 @@ async def _apply_binding_projection(self) -> None: if projection == self._binding_projection: return previous = self._binding_projection - same_groups = ( - previous is not None - and len(previous.groups) == len(projection.groups) - and all( - old.group == new.group - for old, new in zip(previous.groups, projection.groups) - ) - and (previous.palette is None) == (projection.palette is None) - ) - if not same_groups: - await self.recompose() - return - if previous is None or ( - previous.groups != projection.groups or previous.palette != projection.palette - ): - parents = iter(self.children) - for part in projection.groups: - if part.group is not None: - parent = next(parents) - assert isinstance(parent, KeyGroup) - await self._reconcile_keys(parent, part.bindings, grouped=True) - next(parents) # The unchanged group's description label. - else: - # A flat footer is the common case; grouped/flat structural - # transitions retain ordinary recomposition above. - if len(projection.groups) != 1: - await self.recompose() - return - await self._reconcile_keys(self, part.bindings) + mode_label = self.query_one(".-mode", FooterLabel) + if previous.groups != projection.groups or previous.palette != projection.palette: + groups = list(self.query_children(KeyGroup)) + added = [KeyGroup(part) for part in projection.groups[len(groups):]] + if added: + await self.mount_all(added, before=mode_label) + groups.extend(added) + if not self.is_attached or self._closing or self._pruning: + return + existing = {key.action: key for key in self.query(FooterKey) + if not key.has_class("-command-palette")} + desired = {item.binding.action for part in projection.groups for item in part.bindings} + for action in existing.keys() - desired: + await existing.pop(action).remove() + for part, group in zip(projection.groups, groups): + parent = group.update_projection(part) + await self._reconcile_keys(parent, part.bindings, existing, grouped=part.grouped) if not self.is_attached or self._closing or self._pruning: return + for group in groups[len(projection.groups):]: + await group.remove() + palette_keys = [key for key in self.query_children(FooterKey) + if key.has_class("-command-palette")] if projection.palette is not None: - self._update_key( - self.query_one(".-command-palette", FooterKey), - projection.palette, - palette=True, - ) - mode_label = self.query_one(".-mode", FooterLabel) - if previous is None or previous.mode_label != projection.mode_label: + if palette_keys: + self._update_key(palette_keys[0], projection.palette, palette=True) + else: + await self.mount(self._make_key(projection.palette, palette=True)) + elif palette_keys: + await self.remove_children(palette_keys) + if previous.mode_label != projection.mode_label: mode_label.update(projection.mode_label) mode_label.display = bool(projection.mode_label) self.styles.grid_size_columns = projection.columns @@ -425,8 +452,10 @@ def _update_key( (binding.tooltip or binding.description) if palette else (item.tooltip or binding.description) if grouped else item.tooltip ) or None - if (key.key, key.action, key.key_display, key.description, key._disabled, key.tooltip) == ( - binding.key, binding.action, item.key_display, description, not item.enabled, tooltip + if (key.key, key.action, key.key_display, key.description, key._disabled, key.tooltip, + key.has_class("-grouped")) == ( + binding.key, binding.action, item.key_display, description, not item.enabled, tooltip, + grouped ): return changed_size = (key.key_display, key.description) != (item.key_display, description) @@ -434,20 +463,14 @@ def _update_key( key.key_display, key.description = item.key_display, description key._disabled = not item.enabled key.set_class(not item.enabled, "-disabled") + key.set_class(grouped, "-grouped") key.tooltip = tooltip key.refresh(layout=changed_size) async def _reconcile_keys( - self, parent: Widget, items: tuple[FooterBinding, ...], *, grouped: bool = False + self, parent: Widget, items: tuple[FooterBinding, ...], existing: dict[str, FooterKey], + *, grouped: bool = False, ) -> None: - existing = { - key.action: key for key in parent.children - if isinstance(key, FooterKey) and not key.has_class("-command-palette") - } - desired = {item.binding.action for item in items} - removed = [key for action, key in existing.items() if action not in desired] - if removed: - await parent.remove_children(removed) if not parent.is_attached or self._closing or self._pruning: return ordered = [] @@ -456,8 +479,11 @@ async def _reconcile_keys( key = existing.get(item.binding.action) if key is None: key = self._make_key(item, grouped=grouped) + existing[item.binding.action] = key added.append(key) else: + if key.parent is not parent: + key.reparent(parent) self._update_key(key, item, grouped=grouped) ordered.append(key) if added: From dca8bdcc5730eacf2aca54911ce70faff44e4b27 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 18:13:48 -0400 Subject: [PATCH 3/4] Own command maps for mode lifetimes and leave filtered sources untouched --- src/textual/binding.py | 7 ------- src/textual/document/_editing.py | 32 +++++++++++++++---------------- src/textual/screen.py | 17 +++++++--------- src/textual/widgets/_text_area.py | 4 ++-- 4 files changed, 25 insertions(+), 35 deletions(-) diff --git a/src/textual/binding.py b/src/textual/binding.py index 363062c0cb..985f0af4fe 100644 --- a/src/textual/binding.py +++ b/src/textual/binding.py @@ -10,7 +10,6 @@ import dataclasses from dataclasses import dataclass, field from inspect import iscoroutinefunction -from functools import lru_cache from types import FunctionType from typing import ( TYPE_CHECKING, @@ -443,12 +442,6 @@ def __init__( for binding in Binding.make_bindings(bindings or {}): self.key_to_bindings.setdefault(binding.key, []).append(binding) - @classmethod - @lru_cache(maxsize=None) - def editor_commands(cls, owner: type[EditingMode], mode: type[EditingMode]) -> BindingsMap: - """Command declarations depend on the mode types, never a mounted editor.""" - return cls(owner.command_bindings(mode)) - def _add_binding(self, binding: Binding) -> None: """Add a new binding. diff --git a/src/textual/document/_editing.py b/src/textual/document/_editing.py index 9cf780429a..ab7591e0bf 100644 --- a/src/textual/document/_editing.py +++ b/src/textual/document/_editing.py @@ -28,6 +28,15 @@ class EditingMode: modal = False accepts_text = True cursor_shape: ClassVar[CursorShape] = CellCursor() + command_map: BindingsMap + + def __init__(self) -> None: + self.__post_init__() + + def __post_init__(self) -> None: + # Dataclass modes enter here after their count / pending operands exist; + # ordinary entry modes use the same derivation through __init__. + object.__setattr__(self, "command_map", BindingsMap(self.command_bindings())) @property def label(self) -> str: @@ -58,8 +67,7 @@ def apply_native_binding(self, editor: TextArea, binding: MethodBinding, event: finally: editor.editing_mode = editor.editing_mode.settled() - @classmethod - def command_bindings(cls, mode: type[EditingMode]) -> tuple[Binding, ...]: + def command_bindings(self) -> tuple[Binding, ...]: return () @classmethod @@ -117,15 +125,12 @@ def _handle_key(self, editor: TextArea, event: Key) -> bool: def pasted(self) -> EditingMode: return VimInsert() - @classmethod - def command_bindings(cls, mode: type[EditingMode]) -> tuple[Binding, ...]: + def command_bindings(self) -> tuple[Binding, ...]: return tuple(binding for command in EditorCommand.declarations() - for binding in cls.declared_command_bindings(command, mode)) + for binding in self.declared_command_bindings(command)) - @classmethod - def declared_command_bindings(cls, command: type[EditorCommand], - mode: type[EditingMode]) -> tuple[Binding, ...]: - return command.bindings_for(mode) + def declared_command_bindings(self, command: type[EditorCommand]) -> tuple[Binding, ...]: + return command.bindings_for(self.binding_mode) def apply_command(self, editor: TextArea, command: type[EditorCommand]) -> bool: command.apply_mode(editor, self) @@ -323,10 +328,8 @@ def document_changed(self) -> EditingMode: def selection_range(self, editor: TextArea) -> Selection: return self.previous.selection_range(editor) - @classmethod - def declared_command_bindings(cls, command: type[EditorCommand], - mode: type[EditingMode]) -> tuple[Binding, ...]: - return command.pending_bindings(mode) + def declared_command_bindings(self, command: type[EditorCommand]) -> tuple[Binding, ...]: + return command.pending_bindings(self.binding_mode) def _handle_key(self, editor: TextArea, event: Key) -> bool: if editor.edit_key(event): @@ -350,9 +353,6 @@ def edit(self, editor: DOMNode, event: Key) -> bool: class EditorCommand(KeyDeclaration["TextArea"]): keys: ClassVar[tuple[str, ...]] = () - def __init_subclass__(cls, **kwargs) -> None: - super().__init_subclass__(**kwargs) - BindingsMap.editor_commands.cache_clear() _declared: ClassVar[list[type[EditorCommand]]] = [] def __init_subclass__(cls, **kwargs) -> None: diff --git a/src/textual/screen.py b/src/textual/screen.py index a004f742e5..d9b96d6ab0 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -513,17 +513,14 @@ def _binding_chain(self) -> list[tuple[DOMNode, BindingsMap]]: if node not in owners ) - # Filter out bindings that could be captures by widgets (such as Input, TextArea) + # Consume into a projection; node/mode declarations are not a scratch map. filter_namespaces: list[DOMNode] = [] - for namespace, bindings_map in namespace_bindings: - for filter_namespace in filter_namespaces: - check_consume_key = filter_namespace.check_consume_key - for key in list(bindings_map.key_to_bindings): - if check_consume_key(key, key_to_character(key)): - # If the widget consumes the key (e.g. like an Input widget), - # then remove the key from the bindings map. - del bindings_map.key_to_bindings[key] - + for index, (namespace, bindings_map) in enumerate(namespace_bindings): + namespace_bindings[index] = (namespace, BindingsMap.from_keys({ + key: bindings for key, bindings in bindings_map.key_to_bindings.items() + if not any(node.check_consume_key(key, key_to_character(key)) + for node in filter_namespaces) + })) filter_namespaces.append(namespace) keymap = self.app._keymap diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 75212b1c9f..fa8c5a0274 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -156,13 +156,13 @@ def binding_mode(self) -> type[EditingMode]: def binding_map(self, mode: type[EditingMode]) -> BindingsMap: return BindingsMap.merge(( - BindingsMap.editor_commands(type(self.editing_mode), self.binding_mode), + self.editing_mode.command_map, super().binding_map(mode), )) @classmethod def editor_command_bindings(cls, mode: type[EditingMode]) -> tuple[Binding, ...]: - return mode.command_bindings(mode) + return mode().command_bindings() def apply_editor_binding(self, binding: MethodBinding, event: events.Key) -> bool: return self.editing_mode.apply_native_binding(self, binding, event) From 023ae35dabc07d0cc179be5f7243bc7fbc30288f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 18:49:00 -0400 Subject: [PATCH 4/4] Derive native binding defaults once per node class and mode --- src/textual/app.py | 13 ++++++++++++- src/textual/dom.py | 7 ++----- tests/test_binding_inheritance.py | 24 ++++++++++++++---------- tests/test_dom.py | 20 ++++++-------------- 4 files changed, 34 insertions(+), 30 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index f822d7083e..7d8672c9e7 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -889,7 +889,7 @@ def __init__( """Number of current realtime animations, such as scrolling.""" if self.ENABLE_COMMAND_PALETTE: - for _key, binding in self._bindings: + for _key, binding in self._merged_bindings: if binding.action in {"command_palette", "app.command_palette"}: break else: @@ -2050,6 +2050,17 @@ def search_themes(self) -> None: ), ) + @cached_property + def _bindings(self) -> BindingsMap: + """Only runtime App.bind / palette additions are mutable per application.""" + return BindingsMap() + + def binding_map(self, mode: type[EditingMode]) -> BindingsMap: + defaults = self.binding_defaults(mode) + if not self._bindings.key_to_bindings: + return defaults + return BindingsMap.merge((defaults, self._bindings.project(mode))) + def bind( self, keys: str, diff --git a/src/textual/dom.py b/src/textual/dom.py index 2e0778f673..37a756dd99 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -516,10 +516,6 @@ def _nodes(self) -> NodeList: def _component_styles(self) -> dict[str, RenderStyles]: return {} - @cached_property - def _bindings(self) -> BindingsMap: - return self._merged_bindings.copy() - _declared_key_bindings: ClassVar[tuple[MethodBinding, ...]] = () @classmethod @@ -558,13 +554,14 @@ def editor_command_bindings(cls, mode: type[EditingMode]) -> tuple[Binding, ...] return () @classmethod + @lru_cache(maxsize=None) def binding_defaults(cls, mode: type[EditingMode]) -> BindingsMap: """Catalogs see the same raw and nominal native defaults as dispatch.""" return cls._merged_bindings.project(mode, cls.declared_bindings(mode)) def binding_map(self, mode: type[EditingMode]) -> BindingsMap: """One projection for native declarations and external subclass bindings.""" - return self._bindings.project(mode, self.declared_bindings(mode)) + return self.binding_defaults(mode) def edit_key(self, event: Key) -> bool: """Only the focused node consumes the actual decoded input event.""" diff --git a/tests/test_binding_inheritance.py b/tests/test_binding_inheritance.py index 69400f2230..f242c68d5c 100644 --- a/tests/test_binding_inheritance.py +++ b/tests/test_binding_inheritance.py @@ -15,6 +15,7 @@ from textual.app import App, ComposeResult from textual.binding import Binding from textual.containers import Container +from textual.document._editing import EditingMode from textual.screen import Screen from textual.widget import Widget from textual.widgets import Static @@ -39,12 +40,13 @@ class NoBindings(App[None]): async def test_just_app_no_bindings() -> None: """An app with no bindings should have no bindings, other than the app's hard-coded ones.""" async with NoBindings().run_test() as pilot: - assert list(pilot.app._bindings.key_to_bindings.keys()) == [ + bindings = pilot.app.binding_map(EditingMode) + assert list(bindings.key_to_bindings.keys()) == [ "ctrl+q", "ctrl+c", "ctrl+p", ] - assert pilot.app._bindings.get_bindings_for_key("ctrl+q")[0].priority is True + assert bindings.get_bindings_for_key("ctrl+q")[0].priority is True ############################################################################## @@ -65,11 +67,12 @@ class AlphaBinding(App[None]): async def test_just_app_alpha_binding() -> None: """An app with a single binding should have just the one binding.""" async with AlphaBinding().run_test() as pilot: - assert sorted(pilot.app._bindings.key_to_bindings.keys()) == sorted( + bindings = pilot.app.binding_map(EditingMode) + assert sorted(bindings.key_to_bindings.keys()) == sorted( ["ctrl+c", "ctrl+p", "ctrl+q", "a"] ) - assert pilot.app._bindings.get_bindings_for_key("ctrl+q")[0].priority is True - assert pilot.app._bindings.get_bindings_for_key("a")[0].priority is True + assert bindings.get_bindings_for_key("ctrl+q")[0].priority is True + assert bindings.get_bindings_for_key("a")[0].priority is True ############################################################################## @@ -89,11 +92,12 @@ class LowAlphaBinding(App[None]): async def test_just_app_low_priority_alpha_binding() -> None: """An app with a single low-priority binding should have just the one binding.""" async with LowAlphaBinding().run_test() as pilot: - assert sorted(pilot.app._bindings.key_to_bindings.keys()) == sorted( + bindings = pilot.app.binding_map(EditingMode) + assert sorted(bindings.key_to_bindings.keys()) == sorted( ["ctrl+c", "ctrl+p", "ctrl+q", "a"] ) - assert pilot.app._bindings.get_bindings_for_key("ctrl+q")[0].priority is True - assert pilot.app._bindings.get_bindings_for_key("a")[0].priority is False + assert bindings.get_bindings_for_key("ctrl+q")[0].priority is True + assert bindings.get_bindings_for_key("a")[0].priority is False ############################################################################## @@ -122,7 +126,7 @@ def on_mount(self) -> None: async def test_app_screen_with_bindings() -> None: """Test a screen with a single key binding defined.""" async with AppWithScreenThatHasABinding().run_test() as pilot: - assert pilot.app.screen._bindings.get_bindings_for_key("a")[0].priority is True + assert pilot.app.screen.binding_map(EditingMode).get_bindings_for_key("a")[0].priority is True ############################################################################## @@ -151,7 +155,7 @@ def on_mount(self) -> None: async def test_app_screen_with_low_bindings() -> None: """Test a screen with a single low-priority key binding defined.""" async with AppWithScreenThatHasALowBinding().run_test() as pilot: - assert pilot.app.screen._bindings.get_bindings_for_key("a")[0].priority is False + assert pilot.app.screen.binding_map(EditingMode).get_bindings_for_key("a")[0].priority is False ############################################################################## diff --git a/tests/test_dom.py b/tests/test_dom.py index c076639b52..a19d7c4dba 100644 --- a/tests/test_dom.py +++ b/tests/test_dom.py @@ -2,6 +2,7 @@ from textual.css.errors import StyleValueError from textual.dom import BadIdentifier, DOMNode +from textual.document._editing import EditingMode def test_display_default(): @@ -96,20 +97,11 @@ class D(C, inherit_bindings=False): class E(D): BINDINGS = [("e", "e", "e")] - a = A() - assert list(a._bindings.key_to_bindings.keys()) == ["a"] - - b = B() - assert list(b._bindings.key_to_bindings.keys()) == ["a", "b"] - - c = C() - assert list(c._bindings.key_to_bindings.keys()) == ["c"] - - d = D() - assert not list(d._bindings.key_to_bindings.keys()) - - e = E() - assert list(e._bindings.key_to_bindings.keys()) == ["e"] + assert list(A.binding_defaults(EditingMode).key_to_bindings.keys()) == ["a"] + assert list(B.binding_defaults(EditingMode).key_to_bindings.keys()) == ["a", "b"] + assert list(C.binding_defaults(EditingMode).key_to_bindings.keys()) == ["c"] + assert not list(D.binding_defaults(EditingMode).key_to_bindings.keys()) + assert list(E.binding_defaults(EditingMode).key_to_bindings.keys()) == ["e"] def test__get_default_css():