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
15 changes: 11 additions & 4 deletions src/textual/binding.py
Original file line number Diff line number Diff line change
Expand Up @@ -330,15 +330,22 @@ class MethodBinding(ModeBinding):
selecting: tuple[str, ...] = ()

@classmethod
def declare(cls, *keys: str, mode: type[EditingMode], selecting: tuple[str, ...] = ()) -> Callable[[Callable[..., object]], MethodBinding]:
def declare(
cls, *keys: str, selecting: tuple[str, ...] = (), description: str = "",
priority: bool = False, available: Callable[[DOMNode], bool | None] = _always,
show: bool = False,
) -> Callable[[Callable[..., object]], MethodBinding]:
from textual.document._editing import EditingMode

def declared(method: Callable[..., object]) -> MethodBinding:
if not isinstance(method, FunctionType):
raise TypeError("Native method declarations require a Python function")
if iscoroutinefunction(method):
raise TypeError("Pre-text editor methods must be synchronous")
return cls(key=",".join(keys), action=method, mode=mode,
description=method.__name__.removeprefix("action_").replace("_", " ").capitalize(),
tooltip=method.__doc__ or "", show=False, selecting=selecting)
return cls(key=",".join(keys), action=method, mode=EditingMode,
description=description or method.__name__.removeprefix("action_").replace("_", " ").capitalize(),
tooltip=method.__doc__ or "", show=show, selecting=selecting,
priority=priority, available=available)
return declared

@overload
Expand Down
101 changes: 37 additions & 64 deletions src/textual/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,16 @@
from typing_extensions import Final, TypeAlias

from textual import on, work
from textual.binding import Binding, BindingType
from textual.binding import MethodBinding
from textual.containers import Horizontal, Vertical
from textual.content import Content
from textual.events import Click, Mount
from textual.fuzzy import Matcher
from textual.message import Message
from textual.navigation import (
FirstNavigation, LastNavigation, Navigation, NavigationCommand,
PageDown, PageUp, VerticalCursorCommand,
)
from textual.reactive import var
from textual.screen import Screen, SystemModalScreen
from textual.style import Style
Expand Down Expand Up @@ -491,6 +495,16 @@ class CommandList(OptionList, can_focus=False):
"""


def action_cursor_down(self) -> None:
"""Open the native results before stepping their current selection."""
palette = self.query_ancestor(CommandPalette)
if self.option_count and not palette._list_visible:
palette._list_visible = True
self.highlighted = 0
elif self.option_count and self.get_option_at_index(0).id != palette._NO_MATCHES:
super().action_cursor_down()


class SearchIcon(Static, inherit_css=False):
"""Widget for displaying a search icon before the command input."""

Expand Down Expand Up @@ -529,7 +543,22 @@ class CommandInput(Input):
"""


class CommandPalette(SystemModalScreen[None]):
class PaletteBoundaryCommand(NavigationCommand):
"""The palette's modified end keys leave ordinary input Home/End alone."""


class PaletteFirst(FirstNavigation, PaletteBoundaryCommand):
keys = ("ctrl+home", "shift+home")


class PaletteLast(LastNavigation, PaletteBoundaryCommand):
keys = ("ctrl+end", "shift+end")


class CommandPalette(
Navigation, SystemModalScreen[None],
key_families=(VerticalCursorCommand, PageUp, PageDown, PaletteBoundaryCommand),
):
"""The Textual command palette."""

AUTO_FOCUS = "CommandInput"
Expand Down Expand Up @@ -629,36 +658,8 @@ class CommandPalette(SystemModalScreen[None]):
}
"""

BINDINGS: ClassVar[list[BindingType]] = [
Binding(
"ctrl+end, shift+end",
"command_list('last')",
"Go to bottom",
show=False,
),
Binding(
"ctrl+home, shift+home",
"command_list('first')",
"Go to top",
show=False,
),
Binding("down", "cursor_down", "Next command", show=False),
Binding("escape", "escape", "Exit the command palette"),
Binding("pagedown", "command_list('page_down')", "Next page", show=False),
Binding("pageup", "command_list('page_up')", "Previous page", show=False),
Binding("up", "command_list('cursor_up')", "Previous command", show=False),
]
"""
| Key(s) | Description |
| :- | :- |
| ctrl+end, shift+end | Jump to the last available commands. |
| ctrl+home, shift+home | Jump to the first available commands. |
| down | Navigate down through the available commands. |
| escape | Exit the command palette. |
| pagedown | Navigate down a page through the available commands. |
| pageup | Navigate up a page through the available commands. |
| up | Navigate up through the available commands. |
"""
def apply_navigation(self, command: NavigationCommand) -> object:
return command.apply(self.query_one(CommandList))

run_on_select: ClassVar[bool] = True
"""A flag to say if a command should be run when selected by the user.
Expand Down Expand Up @@ -1223,15 +1224,15 @@ def _select_or_command(
# ...so if nothing in the list is highlighted yet...
if command_list.highlighted is None:
# ...cause the first completion to be highlighted.
self._action_cursor_down()
command_list.action_cursor_down()
# If there is one option, assume the user wants to select it
if command_list.option_count == 1:
# Call after a short delay to provide a little visual feedback
self._action_command_list("select")
command_list.action_select_cursor()
else:
# The list is visible, something is highlighted, the user
# made a selection "gesture"; let's go select it!
self._action_command_list("select")
command_list.action_select_cursor()
else:
# The list isn't visible, which means that if we have a
# command...
Expand All @@ -1250,37 +1251,9 @@ def _stop_event_leak(self, event: OptionList.OptionHighlighted) -> None:
event.stop()
self.app.post_message(CommandPalette.OptionHighlighted(highlighted_event=event))

@MethodBinding.declare("escape", description="Exit the command palette", show=True)
def _action_escape(self) -> None:
"""Handle a request to escape out of the command palette."""
self._cancel_gather_commands()
self.app.post_message(CommandPalette.Closed(option_selected=False))
self.dismiss()

def _action_command_list(self, action: str) -> None:
"""Pass an action on to the [`CommandList`][textual.command.CommandList].

Args:
action: The action to pass on to the [`CommandList`][textual.command.CommandList].
"""
try:
command_action = getattr(self.query_one(CommandList), f"action_{action}")
except AttributeError:
return
command_action()

def _action_cursor_down(self) -> None:
"""Handle the cursor down action.

This allows the cursor down key to either open the command list, if
it's closed but has options, or if it's open with options just
cursor through them.
"""
commands = self.query_one(CommandList)
if commands.option_count and not self._list_visible:
self._list_visible = True
commands.highlighted = 0
elif (
commands.option_count
and not commands.get_option_at_index(0).id == self._NO_MATCHES
):
self._action_command_list("cursor_down")
26 changes: 22 additions & 4 deletions src/textual/dom.py
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,8 @@ 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], ...]] = ()

@classmethod
def contexts(cls) -> tuple[type[DOMNode], ...]:
Expand All @@ -545,12 +547,22 @@ def native_bindings(cls, mode: type[EditingMode]) -> tuple[ModeBinding, ...]:
for binding in base.__dict__.get("MODE_BINDINGS", ())
for projected in binding.for_mode(mode))

@classmethod
def present_binding(cls, declaration: type[KeyDeclaration], binding: Binding) -> Binding:
return binding

@classmethod
def declared_bindings(cls, mode: type[EditingMode]) -> tuple[Binding, ...]:
return (*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)))
return (
*(cls.present_binding(declaration, binding)
for family in cls.key_families
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)),
)

@classmethod
def binding_defaults(cls, mode: type[EditingMode]) -> BindingsMap:
Expand Down Expand Up @@ -919,7 +931,13 @@ def __init_subclass__(
inherit_css: bool = True,
inherit_bindings: bool = True,
inherit_component_classes: bool = True,
key_families: tuple[type[KeyDeclaration], ...] = (),
) -> None:
cls._own_key_families = key_families
cls.key_families = tuple(dict.fromkeys(
family for base in cls.__mro__
for family in base.__dict__.get("_own_key_families", ())
))
super().__init_subclass__()

reactives = cls._reactives = {}
Expand Down
11 changes: 8 additions & 3 deletions src/textual/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ class KeyDeclaration(KeyInvocation, Generic[NodeT]):

title = KeyTitle()
system: ClassVar[bool] = False
_declared: ClassVar[list[type[KeyDeclaration]]] = []

def __init_subclass__(cls, **kwargs) -> None:
super().__init_subclass__(**kwargs)
KeyDeclaration._declared.append(cls)

@classmethod
def default_title(cls) -> str:
Expand Down Expand Up @@ -126,10 +131,10 @@ def permits_key_overlap(cls, other: type[KeyDeclaration]) -> bool:
return False

@classmethod
@abstractmethod
def declarations(cls) -> Iterator[type[KeyDeclaration]]:
for provider in cls.__subclasses__():
yield from provider.declarations()
"""Project each key family from the declaration owner's registry."""
return (member for member in KeyDeclaration._declared
if issubclass(member, cls) and not inspect.isabstract(member))


# Adapted from prompt toolkit https://github.com/prompt-toolkit/python-prompt-toolkit/blob/master/prompt_toolkit/keys.py
Expand Down
Loading