Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
131 changes: 62 additions & 69 deletions src/textual/binding.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -20,14 +20,15 @@
Iterator,
Mapping,
NamedTuple,
Self,
TypeVar,
)

import rich.repr

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
Expand All @@ -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."""
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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 ()
Expand Down Expand Up @@ -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),
)

Expand Down Expand Up @@ -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:
Expand All @@ -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__:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
15 changes: 8 additions & 7 deletions src/textual/dom.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]
Expand Down Expand Up @@ -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, ...]] = ()

Expand Down Expand Up @@ -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."""
Expand Down
94 changes: 93 additions & 1 deletion src/textual/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 = {
Expand Down
Loading