diff --git a/src/textual/app.py b/src/textual/app.py index a93db3002a..f822d7083e 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -4271,7 +4271,8 @@ async def on_event(self, event: events.Event) -> None: pass if not await self._check_bindings(event.key, priority=True): forward_target = self.focused or self.screen - forward_target._forward_event(event) + if not await self.screen.dispatch_remapped_key(event, forward_target): + forward_target._forward_event(event) else: self.screen._forward_event(event) diff --git a/src/textual/binding.py b/src/textual/binding.py index 9611fb1282..985f0af4fe 100644 --- a/src/textual/binding.py +++ b/src/textual/binding.py @@ -8,7 +8,7 @@ from __future__ import annotations import dataclasses -from dataclasses import dataclass +from dataclasses import dataclass, field from inspect import iscoroutinefunction from types import FunctionType from typing import ( @@ -20,6 +20,7 @@ Iterator, Mapping, NamedTuple, + Self, TypeVar, ) @@ -27,7 +28,7 @@ from textual._callback import invoke, invoke_sync from textual.actions import SkipAction -from textual.keys import KeyDeclaration, KeyInvocation, _character_to_key +from textual.keys import DeclaredKey, KeyDeclaration, KeyInvocation, UndecodedKey, _character_to_key if TYPE_CHECKING: from textual.document._editing import EditingMode @@ -36,6 +37,7 @@ from textual.app import App from textual.dom import DOMNode from textual.events import Key + from textual.widget import Widget BindingType: TypeAlias = "Binding | tuple[str, str] | tuple[str, str, str]" """The possible types of a binding found in the `BINDINGS` class variable.""" @@ -114,10 +116,21 @@ class Group: group: Group | None = None """Optional binding group (used to group related bindings in the footer).""" - def invocations(self) -> tuple[KeyInvocation, ...]: - """Named external actions expose no nominal invocation.""" + declared_key: DeclaredKey = field(default=UndecodedKey(), kw_only=True, repr=False) + """The original native key identity; remapping changes key, never this fact.""" + + def command_invocations(self) -> tuple[KeyInvocation, ...]: + """Named external actions expose no nominal command.""" return () + def invocations(self) -> tuple[KeyInvocation, ...]: + return self.declared_key.invocations(self.command_invocations()) + + def for_mode(self, mode: type[EditingMode]) -> tuple[Self, ...]: + """Give native arrows their shared preference identity in the actual mode.""" + identity = next((invocation.binding_identity(mode) for invocation in self.invocations()), self.id) + return (dataclasses.replace(self, id=identity),) + def parse_key(self) -> tuple[list[str], str]: """Parse a key into a list of modifiers, and the actual key. @@ -160,6 +173,10 @@ def edit(self, node: DOMNode, event: Key) -> bool: """Ordinary bindings run through application dispatch, after text handling.""" return False + async def run_before_text(self, app: App[object], node: DOMNode, event: Key, target: Widget) -> bool: + """A configured arrow alias keeps its native action, before type-to-search.""" + return await self.run(app, node) + def fallback_bindings(self) -> tuple[Binding, ...]: """Ordinary keys stay on the focused native ancestor chain.""" return () @@ -203,6 +220,7 @@ def make_bindings(cls, bindings: Iterable[BindingType]) -> Iterable[Binding]: yield dataclasses.replace( binding, key=key, + declared_key=binding.declared_key.decoded(key), show=bool(binding.description and binding.show), ) @@ -263,7 +281,7 @@ class ModeBinding(CallBinding, KeyInvocation): def invocation(self) -> KeyInvocation: return self - def invocations(self) -> tuple[KeyInvocation, ...]: + def command_invocations(self) -> tuple[KeyInvocation, ...]: return (self.invocation,) def key_identity(self) -> str: @@ -282,7 +300,7 @@ def for_mode(self, mode: type[EditingMode]) -> tuple[ModeBinding, ...]: """Expose a native callable only in its declared capability's active modes.""" if self.mode not in mode.__mro__: return () - return (dataclasses.replace(self, id=self.invocation.binding_identity(mode)),) + return super().for_mode(mode) def state(self, app: App[object], node: DOMNode) -> bool | None: if self.mode not in node.key_mode.__mro__: @@ -340,6 +358,15 @@ def state(self, app: App[object], node: DOMNode) -> bool | None: return False return node.check_action(self.action.__name__.removeprefix("action_"), self.arguments()) + async def run_before_text(self, app: App[object], node: DOMNode, event: Key, target: Widget) -> bool: + """The native editor, not an earlier dispatch phase, owns this actual Key. + + Forward it once along the ordinary path so pending operators, native + arity, selection and history see precisely the same original mode. + """ + target._forward_event(event) + return True + def edit(self, node: DOMNode, event: Key) -> bool: # Only explicit focused overrides take printable input. Native non-text # keys use this same call while the original prefix still owns its state. @@ -476,74 +503,40 @@ def merge(cls, bindings: Iterable[BindingsMap]) -> BindingsMap: keys.setdefault(key, []).extend(key_bindings) return BindingsMap.from_keys(keys) - def apply_keymap(self, keymap: Keymap) -> KeymapApplyResult: - """Replace bindings for keys that are present in `keymap`. - - Preserves existing bindings for keys that are not in `keymap`. + def project(self, mode: type[EditingMode], declarations: Iterable[BindingType] = ()) -> BindingsMap: + """Raw and nominal declarations use the same native mode projection.""" + return BindingsMap( + projected + for binding in (*[binding for _, binding in self], *Binding.make_bindings(declarations)) + for projected in binding.for_mode(mode) + ) - Args: - keymap: A keymap to overlay. + def apply_keymap(self, keymap: Keymap) -> KeymapApplyResult: + """Overlay preferences without losing native alternatives or expanding aliases twice. - Returns: - KeymapApplyResult: The result of applying the keymap, including any clashed bindings. + Different controls may share an arrow preference, not its implementation. + Within one scope, keep every distinct native binding in its original order; + only identical aliases of the same binding collapse. """ + defaults: dict[str, list[Binding]] = {} + remapped: dict[str, list[Binding]] = {} clashed_bindings: set[Binding] = set() - new_bindings: dict[str, list[Binding]] = {} - applied: set[BindingIDString] = set() - - key_to_bindings = list(self.key_to_bindings.items()) - for key, bindings in key_to_bindings: - for binding in bindings: - binding_id = binding.id - if binding_id is None: - # Bindings without an ID are irrelevant when applying a keymap + for key, binding in self: + binding_id = binding.id + if binding_id is None or binding_id not in keymap: + defaults.setdefault(key, []).append(binding) + continue + for configured_key in keymap[binding_id].split(","): + if not configured_key: continue - - # An override replaces this binding's keys; an empty override unbinds it. - if binding_id in keymap and binding_id not in applied: - applied.add(binding_id) - keymap_keys = [key for key in keymap[binding_id].split(",") if key] - - # Remove this binding from its default keys; other bindings on those keys stay. - for key, _ in key_to_bindings: - key = key.strip() - if key in self.key_to_bindings: - kept = [bound for bound in self.key_to_bindings[key] if bound.id != binding_id] - if kept: - self.key_to_bindings[key] = kept - else: - del self.key_to_bindings[key] - - for keymap_key in keymap_keys: - if ( - keymap_key in self.key_to_bindings - or keymap_key in new_bindings - ): - # The key is already mapped either by default or by the keymap, - # so there's a clash unless the existing binding is being rebound - # to a different key. - clashing_bindings = self.key_to_bindings.get( - keymap_key, [] - ) + new_bindings.get(keymap_key, []) - for clashed_binding in clashing_bindings: - # If the existing binding is not being rebound, it's a clash - if not ( - clashed_binding.id - and keymap.get(clashed_binding.id) - != clashed_binding.key - ): - clashed_bindings.add(clashed_binding) - - if keymap_key in self.key_to_bindings: - del self.key_to_bindings[keymap_key] - - for keymap_key in keymap_keys: - new_bindings.setdefault(keymap_key, []).append( - binding.with_key(key=keymap_key, key_display=None) - ) - - # Update the key_to_bindings with the new bindings - self.key_to_bindings.update(new_bindings) + projected = binding.with_key(configured_key, key_display=None) + alternatives = remapped.setdefault(configured_key, []) + if projected not in alternatives: + alternatives.append(projected) + for key, alternatives in remapped.items(): + clashed_bindings.update(defaults.pop(key, ())) + defaults[key] = alternatives + self.key_to_bindings = defaults return KeymapApplyResult(clashed_bindings) @property diff --git a/src/textual/dom.py b/src/textual/dom.py index 2ea1fb985a..f2ccbc9e96 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -249,7 +249,7 @@ def _make_component_node(self, component: str) -> DOMNode: _css_type_name: str = "" # Generated list of bindings - _merged_bindings: ClassVar[BindingsMap | None] = None + _merged_bindings: ClassVar[BindingsMap] = BindingsMap() _reactives: ClassVar[dict[str, Reactive]] _reactive_accessors: ClassVar[dict[str, _StoredReactiveAccess]] @@ -513,11 +513,7 @@ def _component_styles(self) -> dict[str, RenderStyles]: @cached_property def _bindings(self) -> BindingsMap: - return ( - BindingsMap() - if self._merged_bindings is None - else self._merged_bindings.copy() - ) + return self._merged_bindings.copy() _declared_key_bindings: ClassVar[tuple[MethodBinding, ...]] = () @@ -556,9 +552,14 @@ def editor_command_bindings(cls, mode: type[EditingMode]) -> tuple[Binding, ...] """Only native document editors declare modal document commands.""" return () + @classmethod + 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 BindingsMap.merge((self._bindings, BindingsMap(self.declared_bindings(mode)))) + return self._bindings.project(mode, self.declared_bindings(mode)) def edit_key(self, event: Key) -> bool: """Only the focused node consumes the actual decoded input event.""" diff --git a/src/textual/keys.py b/src/textual/keys.py index c0985db8b3..ca7b509b73 100644 --- a/src/textual/keys.py +++ b/src/textual/keys.py @@ -4,14 +4,16 @@ import inspect import re from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Callable, ClassVar, Generic, Iterator, TypeVar +from typing import TYPE_CHECKING, Callable, ClassVar, Generic, Iterator, Mapping, TypeVar from enum import Enum +from dataclasses import dataclass from functools import lru_cache if TYPE_CHECKING: from textual.binding import Binding from textual.document._editing import EditingMode from textual.dom import DOMNode + from textual.events import Key NodeT = TypeVar("NodeT", bound="DOMNode", contravariant=True) @@ -42,6 +44,10 @@ def binding_identity(self, mode: type[EditingMode]) -> str: def in_mode(identity: str, mode: type) -> str: return f"{identity}:{KeyInvocation.qualified(mode)}" + def visible_for_focus(self, mode: type[EditingMode], focused: frozenset[str]) -> bool: + """Local commands follow the enabled invocation IDs of the previous focus.""" + return self.binding_identity(mode) in focused + def permits_invocation_overlap(self, other: KeyInvocation) -> bool: return False @@ -321,6 +327,92 @@ def value(self) -> str: ShiftControlEnd = ControlShiftEnd +@dataclass(frozen=True) +class DeclaredKey: + """A decoded native input identity, distinct from its configured spelling.""" + + def decoded(self, key: str) -> DeclaredKey: + return self + + def invocations(self, defaults: tuple[KeyInvocation, ...]) -> tuple[KeyInvocation, ...]: + return defaults + + def preempts(self, binding: Binding, event: Key, keymap: Mapping[str, str]) -> bool: + return False + + +class UndecodedKey(DeclaredKey): + """Only Binding.make_bindings sees an external declaration's key spelling.""" + + def decoded(self, key: str) -> DeclaredKey: + return NavigationKey.from_key(key) + + +class NavigationKey(DeclaredKey, KeyInvocation): + """One preference for an arrow, independent of the focused control's effect. + + Bindings keep their native callable, availability and order. Only the saved + input identity is shared: configuring Up must not mean configuring TextArea, + a table, a tree and every future arrow control separately. + """ + + key: ClassVar[Keys] + _by_key: ClassVar[dict[str, type[NavigationKey]]] = {} + + def __init_subclass__(cls, *, key: Keys, **kwargs: object) -> None: + super().__init_subclass__(**kwargs) + cls.key = key + cls._by_key[key.value] = cls + + @classmethod + def from_key(cls, key: str) -> DeclaredKey: + """Decode a declared native key once, before any preference changes it.""" + try: + declaration = cls._by_key[key] + except KeyError: + return DeclaredKey() + return declaration() + + def invocations(self, defaults: tuple[KeyInvocation, ...]) -> tuple[KeyInvocation, ...]: + return (self,) + + def preempts(self, binding: Binding, event: Key, keymap: Mapping[str, str]) -> bool: + """Explicit printable aliases precede a list's type-to-search behavior.""" + return event.is_printable and binding.id in keymap + + def key_identity(self) -> str: + return self.qualified(type(self)) + + def key_title(self) -> str: + return f"Cursor {self.key.value}" + + def key_summary(self) -> str: + return f"Use these keys wherever the focused control handles {self.key.value}." + + def key_system(self) -> bool: + return False + + def visible_for_focus(self, mode: type[EditingMode], focused: frozenset[str]) -> bool: + """Global arrow preferences remain editable even from a non-arrow focus.""" + return True + + +class UpKey(NavigationKey, key=Keys.Up): + pass + + +class DownKey(NavigationKey, key=Keys.Down): + pass + + +class LeftKey(NavigationKey, key=Keys.Left): + pass + + +class RightKey(NavigationKey, key=Keys.Right): + pass + + # Unicode db contains some obscure names # This mapping replaces them with more common terms KEY_NAME_REPLACEMENTS = { diff --git a/src/textual/screen.py b/src/textual/screen.py index a241ee742b..71c3da882a 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -538,6 +538,20 @@ def _modal_binding_chain(self) -> list[tuple[DOMNode, BindingsMap]]: return binding_chain[:index] return binding_chain + async def dispatch_remapped_key(self, event: events.Key, target: Widget) -> bool: + """Explicit arrow aliases use the existing modal chain before text search. + + This is the original decoded event, never a manufactured arrow or a + second delivery. Native editor bindings forward it to their mode owner; + ordinary controls execute their own declared action. + """ + for node, bindings in self._modal_binding_chain: + for binding in bindings.key_to_bindings.get(event.key, ()): + if binding.declared_key.preempts(binding, event, self.app._keymap): + if await binding.run_before_text(self.app, node, event, target): + return True + return False + @property def active_bindings(self) -> dict[str, ActiveBinding]: """Get currently active bindings for this screen.