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
10 changes: 9 additions & 1 deletion src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions src/textual/binding.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
26 changes: 18 additions & 8 deletions src/textual/dom.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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], ...]] = ()
Expand Down Expand Up @@ -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."""
Expand Down
17 changes: 7 additions & 10 deletions src/textual/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
162 changes: 94 additions & 68 deletions src/textual/widgets/_footer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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):
Expand Down Expand Up @@ -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;
}
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 (
Expand Down Expand Up @@ -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
Expand All @@ -425,29 +452,25 @@ 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)
key.key, key.action = binding.key, binding.action
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 = []
Expand All @@ -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:
Expand Down
Loading