diff --git a/src/textual/app.py b/src/textual/app.py index e6c5350b97..4543709eca 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,14 @@ 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: + return BindingsMap.merge((self.binding_defaults(mode), self._bindings.project(mode))) + def bind( self, keys: str, diff --git a/src/textual/binding.py b/src/textual/binding.py index 89d796cf6b..728d6b9fef 100644 --- a/src/textual/binding.py +++ b/src/textual/binding.py @@ -439,6 +439,19 @@ class ActiveBinding(NamedTuple): """Optional tooltip shown in Footer.""" +@dataclass(frozen=True) +class KeymapClaim: + """A detached default claim, with its original nominal invocation intact.""" + + invocation: KeyInvocation + keys: tuple[str, ...] + + @classmethod + def from_bindings(cls, bindings: Iterable[Binding]) -> Iterator[KeymapClaim]: + return (cls(invocation, (key,)) for key, binding in BindingsMap(bindings) + for invocation in binding.invocations()) + + @rich.repr.auto class BindingsMap: """Manage a set of bindings.""" diff --git a/src/textual/dom.py b/src/textual/dom.py index 74df5c893e..cfcdb7f027 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -37,7 +37,7 @@ from textual._node_list import NodeList from textual._paint_state import PaintNode from textual._types import WatchCallbackType -from textual.binding import Binding, BindingsMap, BindingType, MethodBinding, ModeBinding +from textual.binding import Binding, BindingsMap, BindingType, KeymapClaim, MethodBinding, ModeBinding from textual.document._editing import EditingMode from textual.cache import LRUCache from textual.css._error_tools import friendly_list @@ -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, ...]] = () _own_key_families: ClassVar[tuple[type[KeyDeclaration], ...]] = () key_families: ClassVar[tuple[type[KeyDeclaration], ...]] = () @@ -564,14 +560,28 @@ def declared_bindings(cls, mode: type[EditingMode]) -> tuple[Binding, ...]: for projected in binding.for_mode(mode)), ) + @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()) + @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)) + """Catalogs and dispatch receive their own map over the same frozen bindings.""" + return BindingsMap.from_keys({ + key: list(bindings) for key, bindings in cls._binding_defaults(mode) + }) + + @classmethod + def keymap_claims(cls, mode: type[EditingMode]) -> tuple[KeymapClaim, ...]: + """A native scope exposes its declared invocations without constructing controls.""" + return tuple(KeymapClaim.from_bindings(binding for _, binding in cls.binding_defaults(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/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/_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: 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():