diff --git a/src/textual/dom.py b/src/textual/dom.py index 55c489bd78..a085922f33 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -260,6 +260,8 @@ def _make_component_node(self, component: str) -> DOMNode: # Generated list of bindings _merged_bindings: ClassVar[BindingsMap] = BindingsMap() + _own_bindings: ClassVar[BindingsMap] = BindingsMap() + """This class's own BINDINGS, parsed once when the class is created.""" _reactives: ClassVar[dict[str, Reactive]] _reactive_accessors: ClassVar[dict[str, _StoredReactiveAccess]] @@ -566,17 +568,55 @@ def declared_bindings(cls, mode: type[EditingMode]) -> tuple[Binding, ...]: for declaration in family.declarations() for binding in declaration.bindings_for(mode)), *cls.native_bindings(mode), - *(projected for base in cls.__mro__ - for binding in base.__dict__.get("_declared_key_bindings", ()) - for projected in binding.for_mode(mode)), + *cls.method_bindings(mode), ) + @classmethod + def method_bindings(cls, mode: type[EditingMode]) -> tuple[Binding, ...]: + """Keys each class in the MRO declares on its own methods.""" + return tuple(projected for base in cls.__mro__ + for binding in base.__dict__.get("_declared_key_bindings", ()) + for projected in binding.for_mode(mode)) + + @classmethod + def class_bindings(cls, mode: type[EditingMode]) -> dict[str, list[Binding]]: + """Each class's own keys replace its ancestors' bindings for those keys. + + A class writes a key either in BINDINGS or by declaring it on a method; both are + that class's word on the key, so the most-derived class wins whichever form it + used (TextArea's declared End over ScrollableContainer's End BINDINGS). + """ + keys: dict[str, list[Binding]] = {} + for base in cls._binding_bases(): + keys.update(base._own_bindings.project( + mode, base.__dict__.get("_declared_key_bindings", ())).key_to_bindings) + return keys + + @classmethod + def _binding_bases(cls) -> list[type[DOMNode]]: + """The DOMNode bases whose keys this class inherits, oldest first: the walk restarts + at any base that does not inherit its ancestors' bindings.""" + bases: list[type[DOMNode]] = [] + for base in reversed(cls.__mro__): + if issubclass(base, DOMNode): + if not base._inherit_bindings: + bases.clear() + bases.append(base) + return bases + @classmethod @lru_cache(maxsize=None) def _binding_defaults(cls, mode: type[EditingMode]) -> tuple[tuple[str, tuple[Binding, ...]], ...]: """Cache immutable projections, never a caller's mutable remapping table.""" - projected = cls._merged_bindings.project(mode, cls.declared_bindings(mode)) - return tuple((key, tuple(bindings)) for key, bindings in projected.key_to_bindings.items()) + keys = {key: list(bindings) for key, bindings in cls.class_bindings(mode).items()} + # A method's key was placed, or replaced, with its class above; the families follow. + placed = cls.method_bindings(mode) + for binding in Binding.make_bindings( + declared for declared in cls.declared_bindings(mode) if declared not in placed): + for projected in binding.for_mode(mode): + if projected not in (bindings := keys.setdefault(projected.key, [])): + bindings.append(projected) + return tuple((key, tuple(bindings)) for key, bindings in keys.items()) @classmethod def binding_defaults(cls, mode: type[EditingMode]) -> BindingsMap: @@ -984,6 +1024,7 @@ def __init_subclass__( cls._css_type_name = bases[0].__name__ for base in bases: css_type_names.add(base.__name__) + cls._own_bindings = BindingsMap(cls.__dict__.get("BINDINGS", [])) cls._merged_bindings = cls._merge_bindings() cls._css_type_names = frozenset(css_type_names) compute_methods = frozenset( @@ -1075,22 +1116,9 @@ def _merge_bindings(cls) -> BindingsMap: Returns: Merged bindings. """ - bindings: list[BindingsMap] = [] - - for base in reversed(cls.__mro__): - if issubclass(base, DOMNode): - if not base._inherit_bindings: - bindings.clear() - bindings.append( - BindingsMap( - base.__dict__.get("BINDINGS", []), - ) - ) - keys: dict[str, list[Binding]] = {} - for bindings_ in bindings: - for key, key_bindings in bindings_.key_to_bindings.items(): - keys[key] = key_bindings + for base in cls._binding_bases(): + keys.update(base._own_bindings.key_to_bindings) new_bindings = BindingsMap.from_keys(keys) return new_bindings diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index eefcd4f4ad..ff9743cb3b 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -767,7 +767,8 @@ def update_suggestion(self) -> None: @property def allows_application_commands(self) -> bool: - return self.editing_mode.allows_application_commands + """A read-only area takes no typed text, so the application's shortcuts reach past it.""" + return self.read_only or self.editing_mode.allows_application_commands def check_consume_key(self, key: str, character: str | None = None) -> bool: """Check if the widget may consume the given key.