Merge pull request #3058 from davep/M-x

Command palette
This commit is contained in:
Dave Pearson
2023-09-11 15:09:03 +01:00
committed by GitHub
25 changed files with 1959 additions and 456 deletions

View File

@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Added
- Added the command palette https://github.com/Textualize/textual/pull/3058
- `Input` is now validated when focus moves out of it https://github.com/Textualize/textual/pull/3193
- Attribute `Input.validate_on` (and `__init__` parameter of the same name) to customise when validation occurs https://github.com/Textualize/textual/pull/3193
- Screen-specific (sub-)title attributes https://github.com/Textualize/textual/pull/3199:
@@ -28,6 +29,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Breaking change: Widget.notify and App.notify now return None https://github.com/Textualize/textual/pull/3275
- App.unnotify is now private (renamed to App._unnotify) https://github.com/Textualize/textual/pull/3275
## [0.36.0] - 2023-09-05
### Added

135
docs/api/command_palette.md Normal file
View File

@@ -0,0 +1,135 @@
!!! tip "Added in version 0.37.0"
## Introduction
The command palette provides a system-wide facility for searching for and
executing commands. These commands are added by creating command source
classes and declaring them on your [application](../../guide/app/) or your
[screens](../../guide/screens/).
Note that `CommandPalette` itself isn't designed to be used directly in your
applications; it is instead something that is enabled by default and is made
available by the Textual [`App`][textual.app.App] class. If you wish to
disable the availability of the command palette you can set the
[`use_command_palette`][textual.app.App.use_command_palette] switch to
`False`.
## Creating a command source
To add your own command source to the Textual command palette you start by
creating a class that inherits from
[`CommandSource`][textual.command_palette.CommandSource]. Your new command
source class should implement the
[`search`][textual.command_palette.CommandSource.search] method. This
should be an `async` method which `yield`s instances of
[`CommandSourceHit`][textual.command_palette.CommandSourceHit].
For example, suppose we wanted to create a command source that would look
through the globals in a running application and use
[`notify`][textual.app.App.notify] to show the docstring (admittedly not the
most useful command source, but illustrative of a source of text to match
and code to run).
The command source might look something like this:
```python
from functools import partial
# ...
class PythonGlobalSource(CommandSource):
"""A command palette source for globals in an app."""
async def search(self, query: str) -> CommandMatches:
# Create a fuzzy matching object for the query.
matcher = self.matcher(query)
# Looping throught the available globals...
for name, value in globals().items():
# Get a match score for the name.
match = matcher.match(name)
# If the match is above 0...
if match:
# ...pass the command up to the palette.
yield CommandSourceHit(
# The match score.
match,
# A highlighted version of the matched item,
# showing how and where it matched.
matcher.highlight(name),
# The code to run. Here we'll call the Textual
# notification system and get it to show the
# docstring for the chosen item, if there is
# one.
partial(
self.app.notify,
value.__doc__ or "[i]Undocumented[/i]",
title=name
),
# The plain text that was selected.
name
)
```
!!! important
The command palette populates itself asynchronously, pulling matches from
all of the active sources. Your command source `search` method must be
`async`, and must not block in any way; doing so will affect the
performance of the user's experience while using the command palette.
The key point here is that the `search` method should look for matches,
given the user input, and yield up a
[`CommandSourceHit`][textual.command_palette.CommandSourceHit], which will
contain the match score (which should be between 0 and 1), a Rich renderable
(such as a [rich Text object][rich.text.Text]) to illustrate how the command
was matched (this appears in the drop-down list of the command palette), a
reference to a function to run when the user selects that command, and the
plain text version of the command.
## Unhandled exceptions in a command source
When writing your command source `search` method you should attempt to
handle all possible errors. In the event that there is an unhandled
exception Textual will carry on working and carry on taking results from any
other registered command sources.
!!! important
This is different from how Textual normally works. Under normal
circumstances Textual would not "hide" your errors.
Textual doesn't just throw the exception away though. If an exception isn't
handled by your code it will be logged to [the Textual devtools
console](../../guide/devtools#console).
## Using a command source
Once a command source has been created it can be used either on an `App` or
a `Screen`; this is done with the [`COMMAND_SOURCES` class variable][textual.app.App.COMMAND_SOURCES]. One or more command sources can
be given. For example:
```python
class MyApp(App[None]):
COMMAND_SOURCES = {MyCommandSource, MyOtherCommandSource}
```
When the command palette is called by the user, those sources will be used
to populate the list of search hits.
!!! tip
If you wish to use your own commands sources on your appliaction, and
you wish to keep using the default Textual command sources, be sure to
include the ones provided by [`App`][textual.app.App.COMMAND_SOURCES].
For example:
```python
class MyApp(App[None]):
COMMAND_SOURCES = App.COMMAND_SOURCES | {MyCommandSource, MyOtherCommandSource}
```
## API documentation
::: textual.command_palette

View File

@@ -0,0 +1 @@
::: textual._fuzzy

View File

@@ -0,0 +1 @@
::: textual._system_commands_source

View File

@@ -11,7 +11,6 @@ import sys
from rich.syntax import Syntax
from rich.traceback import Traceback
from textual import events
from textual.app import App, ComposeResult
from textual.containers import Container, VerticalScroll
from textual.reactive import var
@@ -43,7 +42,7 @@ class CodeBrowser(App):
yield Static(id="code", expand=True)
yield Footer()
def on_mount(self, event: events.Mount) -> None:
def on_mount(self) -> None:
self.query_one(DirectoryTree).focus()
def on_directory_tree_file_selected(

View File

@@ -168,12 +168,14 @@ nav:
- "api/await_remove.md"
- "api/binding.md"
- "api/color.md"
- "api/command_palette.md"
- "api/containers.md"
- "api/coordinate.md"
- "api/dom_node.md"
- "api/events.md"
- "api/errors.md"
- "api/filter.md"
- "api/fuzzy_matcher.md"
- "api/geometry.md"
- "api/logger.md"
- "api/logging.md"
@@ -189,6 +191,7 @@ nav:
- "api/scroll_view.md"
- "api/strip.md"
- "api/suggester.md"
- "api/system_commands_source.md"
- "api/timer.md"
- "api/types.md"
- "api/validation.md"

541
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -65,7 +65,7 @@ httpx = "^0.23.1"
types-setuptools = "^67.2.0.1"
textual-dev = "^1.1.0"
pytest-asyncio = "*"
pytest-textual-snapshot = "0.2.0"
pytest-textual-snapshot = "*"
[tool.black]
includes = "src"

View File

@@ -1,6 +1,9 @@
from re import compile, escape
from __future__ import annotations
from re import IGNORECASE, compile, escape
import rich.repr
from rich.style import Style
from rich.text import Text
from ._cache import LRUCache
@@ -10,29 +13,61 @@ from ._cache import LRUCache
class Matcher:
"""A fuzzy matcher."""
def __init__(self, query: str) -> None:
"""
def __init__(
self,
query: str,
*,
match_style: Style | None = None,
case_sensitive: bool = False,
) -> None:
"""Initialise the fuzzy matching object.
Args:
query: A query as typed in by the user.
match_style: The style to use to highlight matched portions of a string.
case_sensitive: Should matching be case sensitive?
"""
self.query = query
self._query_regex = ".*?".join(f"({escape(character)})" for character in query)
self._query_regex_compiled = compile(self._query_regex)
self._query = query
self._match_style = Style(reverse=True) if match_style is None else match_style
self._query_regex = compile(
".*?".join(f"({escape(character)})" for character in query),
flags=0 if case_sensitive else IGNORECASE,
)
self._cache: LRUCache[str, float] = LRUCache(1024 * 4)
def match(self, input: str) -> float:
"""Match the input against the query
@property
def query(self) -> str:
"""The query string to look for."""
return self._query
@property
def match_style(self) -> Style:
"""The style that will be used to highlight hits in the matched text."""
return self._match_style
@property
def query_pattern(self) -> str:
"""The regular expression pattern built from the query."""
return self._query_regex.pattern
@property
def case_sensitive(self) -> bool:
"""Is this matcher case sensitive?"""
return not bool(self._query_regex.flags & IGNORECASE)
def match(self, candidate: str) -> float:
"""Match the candidate against the query.
Args:
input: Input string to match against.
candidate: Candidate string to match against the query.
Returns:
Strength of the match from 0 to 1.
"""
cached = self._cache.get(input)
cached = self._cache.get(candidate)
if cached is not None:
return cached
match = self._query_regex_compiled.search(input)
match = self._query_regex.search(candidate)
if match is None:
score = 0.0
else:
@@ -47,21 +82,21 @@ class Matcher:
group_count += 1
last_offset = offset
score = 1.0 - ((group_count - 1) / len(input))
self._cache[input] = score
score = 1.0 - ((group_count - 1) / len(candidate))
self._cache[candidate] = score
return score
def highlight(self, input: str) -> Text:
"""Highlight the input with the fuzzy match.
def highlight(self, candidate: str) -> Text:
"""Highlight the candidate with the fuzzy match.
Args:
input: User input.
candidate: The candidate string to match against the query.
Returns:
A Text object with matched letters in bold.
A [rich.text.Text][`Text`] object with highlighted matches.
"""
match = self._query_regex_compiled.search(input)
text = Text(input)
match = self._query_regex.search(candidate)
text = Text(candidate)
if match is None:
return text
assert match.lastindex is not None
@@ -69,14 +104,55 @@ class Matcher:
match.span(group_no)[0] for group_no in range(1, match.lastindex + 1)
]
for offset in offsets:
text.stylize("bold", offset, offset + 1)
text.stylize(self._match_style, offset, offset + 1)
return text
if __name__ == "__main__":
from itertools import permutations
from string import ascii_lowercase
from time import monotonic
from rich import print
from rich.rule import Rule
matcher = Matcher("foo.bar")
print(matcher.match("xz foo.bar sdf"))
print(matcher.highlight("xz foo.bar sdf"))
print(Rule())
print("Query is:", matcher.query)
print("Rule is:", matcher.query_pattern)
print(Rule())
candidates = (
"foo.bar",
" foo.bar ",
"Hello foo.bar world",
"f o o . b a r",
"f o o .bar",
"foo. b a r",
"Lots of text before the foo.bar",
"foo.bar up front and then lots of text afterwards",
"This has an o in it but does not have a match",
"Let's find one obvious match. But blat around always roughly.",
)
results = sorted(
[
(matcher.match(candidate), matcher.highlight(candidate))
for candidate in candidates
],
key=lambda pair: pair[0],
reverse=True,
)
for score, highlight in results:
print(f"{score:.15f} '", highlight, "'", sep="")
print(Rule())
RUNS = 5
candidates = [
"".join(permutation) for permutation in permutations(ascii_lowercase[:10])
]
matcher = Matcher(ascii_lowercase[:10])
start = monotonic()
for _ in range(RUNS):
for candidate in candidates:
_ = matcher.match(candidate)
print(f"{RUNS * len(candidates)} matches in {monotonic() - start:.5f} seconds")

View File

@@ -0,0 +1,56 @@
"""A command palette command source for Textual system commands.
This is a simple command source that makes the most obvious application
actions available via the [command palette][textual.command_palette.CommandPalette].
"""
from .command_palette import CommandMatches, CommandSource, CommandSourceHit
class SystemCommandSource(CommandSource):
"""A [source][textual.command_palette.CommandSource] of command palette commands that run app-wide tasks.
Used by default in [`App.COMMAND_SOURCES`][textual.app.App.COMMAND_SOURCES].
"""
async def search(self, query: str) -> CommandMatches:
"""Handle a request to search for system commands that match the query.
Args:
user_input: The user input to be matched.
Yields:
Command source hits for use in the command palette.
"""
# We're going to use Textual's builtin fuzzy matcher to find
# matching commands.
matcher = self.matcher(query)
# Loop over all applicable commands, find those that match and offer
# them up to the command palette.
for name, runnable, help_text in (
(
"Toggle light/dark mode",
self.app.action_toggle_dark,
"Toggle the application between light and dark mode",
),
(
"Quit the application",
self.app.action_quit,
"Quit the application as soon as possible",
),
(
"Ring the bell",
self.app.action_bell,
"Ring the terminal's 'bell'",
),
):
match = matcher.match(name)
if match > 0:
yield CommandSourceHit(
match,
matcher.highlight(name),
runnable,
name,
help_text,
)

View File

@@ -68,11 +68,13 @@ from ._context import active_app, active_message_pump
from ._context import message_hook as message_hook_context_var
from ._event_broker import NoHandler, extract_handler_actions
from ._path import CSSPathType, _css_path_type_as_list, _make_path_object_relative
from ._system_commands_source import SystemCommandSource
from ._wait import wait_for_idle
from ._worker_manager import WorkerManager
from .actions import ActionParseResult, SkipAction
from .await_remove import AwaitRemove
from .binding import Binding, BindingType, _Bindings
from .command_palette import CommandPalette, CommandPaletteCallable, CommandSource
from .css.query import NoMatches
from .css.stylesheet import Stylesheet
from .design import ColorSystem
@@ -323,8 +325,23 @@ class App(Generic[ReturnType], DOMNode):
See also [the `Screen.SUB_TITLE` attribute][textual.screen.Screen.SUB_TITLE].
"""
ENABLE_COMMAND_PALETTE: ClassVar[bool] = True
"""Should the [command palette][textual.command_palette.CommandPalette] be enabled for the application?"""
COMMAND_SOURCES: ClassVar[set[type[CommandSource]]] = {SystemCommandSource}
"""The [command sources](/api/command_palette/) for the application.
This is the collection of [command sources][textual.command_palette.CommandSource]
that provide matched
commands to the [command palette][textual.command_palette.CommandPalette].
The default Textual command palette source is
[the Textual system-wide command source][textual._system_commands_source.SystemCommandSource].
"""
BINDINGS: ClassVar[list[BindingType]] = [
Binding("ctrl+c", "quit", "Quit", show=False, priority=True)
Binding("ctrl+c", "quit", "Quit", show=False, priority=True),
Binding("ctrl+@", "command_palette", show=False, priority=True),
]
title: Reactive[str] = Reactive("", compute=False)
@@ -438,6 +455,14 @@ class App(Generic[ReturnType], DOMNode):
The new value is always converted to string.
"""
self.use_command_palette: bool = self.ENABLE_COMMAND_PALETTE
"""A flag to say if the application should use the command palette.
If set to `False` any call to
[`action_command_palette`][textual.app.App.action_command_palette]
will be ignored.
"""
self._logger = Logger(self._log)
self._refresh_required = False
@@ -3003,3 +3028,8 @@ class App(Generic[ReturnType], DOMNode):
"""Clear all the current notifications."""
self._notifications.clear()
self._refresh_notifications()
def action_command_palette(self) -> None:
"""Show the Textual command palette."""
if self.use_command_palette and not CommandPalette.is_open(self):
self.push_screen(CommandPalette(), callback=self.call_next)

View File

@@ -0,0 +1,892 @@
"""The Textual command palette."""
from __future__ import annotations
from abc import ABC, abstractmethod
from asyncio import CancelledError, Queue, TimeoutError, wait_for
from functools import total_ordering
from time import monotonic
from typing import (
TYPE_CHECKING,
Any,
AsyncGenerator,
AsyncIterator,
Callable,
ClassVar,
NamedTuple,
)
from rich.align import Align
from rich.console import Group, RenderableType
from rich.emoji import Emoji
from rich.style import Style
from rich.text import Text
from rich.traceback import Traceback
from typing_extensions import Final, TypeAlias
from . import on, work
from ._asyncio import create_task
from ._fuzzy import Matcher
from .binding import Binding, BindingType
from .containers import Horizontal, Vertical
from .events import Click, Mount
from .reactive import var
from .screen import ModalScreen, Screen
from .timer import Timer
from .widget import Widget
from .widgets import Button, Input, LoadingIndicator, OptionList, Static
from .widgets.option_list import Option
from .worker import get_current_worker
if TYPE_CHECKING:
from .app import App, ComposeResult
__all__ = [
"CommandMatches",
"CommandPalette",
"CommandPaletteCallable",
"CommandSource",
"CommandSourceHit",
"Matcher",
]
CommandPaletteCallable: TypeAlias = Callable[[], Any]
"""The type of a function that will be called when a command is selected from the command palette."""
@total_ordering
class CommandSourceHit(NamedTuple):
"""Holds the details of a single command search hit."""
match_value: float
"""The match value of the command hit.
The value should be between 0 (no match) and 1 (complete match).
"""
match_display: RenderableType
"""The Rich renderable representation of the hit.
Ideally a [rich Text object][rich.text.Text] object or similar.
"""
command: CommandPaletteCallable
"""The function to call when the command is chosen."""
command_text: str
"""The command text associated with the hit, as plain text.
This is the text that will be placed into the `Input` field of the
[command palette][textual.command_palette.CommandPalette] when a
selection is made.
"""
command_help: str | None = None
"""Optional help text for the command."""
def __lt__(self, other: object) -> bool:
if isinstance(other, CommandSourceHit):
return self.match_value < other.match_value
return NotImplemented
def __eq__(self, other: object) -> bool:
if isinstance(other, CommandSourceHit):
return self.match_value == other.match_value
return NotImplemented
CommandMatches: TypeAlias = AsyncIterator[CommandSourceHit]
"""Return type for the command source match searching method."""
class CommandSource(ABC):
"""Base class for command palette command sources.
To create a source of commands inherit from this class and implement
[`search`][textual.command_palette.CommandSource.search].
"""
def __init__(self, screen: Screen[Any], match_style: Style | None = None) -> None:
"""Initialise the command source.
Args:
screen: A reference to the active screen.
"""
self.__screen = screen
self.__match_style = match_style
@property
def focused(self) -> Widget | None:
"""The currently-focused widget in the currently-active screen in the application.
If no widget has focus this will be `None`.
"""
return self.__screen.focused
@property
def screen(self) -> Screen[object]:
"""The currently-active screen in the application."""
return self.__screen
@property
def app(self) -> App[object]:
"""A reference to the application."""
return self.__screen.app
@property
def match_style(self) -> Style | None:
"""The preferred style to use when highlighting matching portions of the [`match_display`][textual.command_palette.CommandSourceHit.match_display]."""
return self.__match_style
def matcher(self, user_input: str, case_sensitive: bool = False) -> Matcher:
"""Create a [fuzzy matcher][textual._fuzzy.Matcher] for the given user input.
Args:
user_input: The text that the user has input.
case_sensitive: Should match be case sensitive?
Returns:
A [fuzzy matcher][textual._fuzzy.Matcher] object for matching against candidate hits.
"""
return Matcher(
user_input, match_style=self.match_style, case_sensitive=case_sensitive
)
@abstractmethod
async def search(self, query: str) -> CommandMatches:
"""A request to search for commands relevant to the given query.
Args:
query: The user input to be matched.
Yields:
Instances of [`CommandSourceHit`][textual.command_palette.CommandSourceHit].
"""
yield NotImplemented
@total_ordering
class Command(Option):
"""Class that holds a command in the [`CommandList`][textual.command_palette.CommandList]."""
def __init__(
self,
prompt: RenderableType,
command: CommandSourceHit,
id: str | None = None,
disabled: bool = False,
) -> None:
"""Initialise the option.
Args:
prompt: The prompt for the option.
command: The details of the command associated with the option.
id: The optional ID for the option.
disabled: The initial enabled/disabled state. Enabled by default.
"""
super().__init__(prompt, id, disabled)
self.command = command
"""The details of the command associated with the option."""
def __lt__(self, other: object) -> bool:
if isinstance(other, Command):
return self.command < other.command
return NotImplemented
def __eq__(self, other: object) -> bool:
if isinstance(other, Command):
return self.command == other.command
return NotImplemented
class CommandList(OptionList, can_focus=False):
"""The command palette command list."""
DEFAULT_CSS = """
CommandList {
visibility: hidden;
border-top: blank;
border-bottom: hkey $accent;
border-left: none;
border-right: none;
height: auto;
max-height: 70vh;
background: $panel;
}
CommandList:focus {
border: blank;
}
CommandList.--visible {
visibility: visible;
}
CommandList.--populating {
border-bottom: none;
}
CommandList > .option-list--option-highlighted {
background: $accent;
}
CommandList > .option-list--option {
padding-left: 1;
}
"""
class SearchIcon(Static, inherit_css=False):
"""Widget for displaying a search icon before the command input."""
DEFAULT_CSS = """
SearchIcon {
margin-left: 1;
margin-top: 1;
width: 2;
}
"""
icon: var[str] = var(Emoji.replace(":magnifying_glass_tilted_right:"))
"""The icon to display."""
def render(self) -> RenderableType:
"""Render the icon.
Returns:
The icon renderable.
"""
return self.icon
class CommandInput(Input):
"""The command palette input control."""
DEFAULT_CSS = """
CommandInput, CommandInput:focus {
border: blank;
width: 1fr;
background: $panel;
padding-left: 0;
}
"""
class CommandPalette(ModalScreen[CommandPaletteCallable], inherit_css=False):
"""The Textual command palette."""
COMPONENT_CLASSES: ClassVar[set[str]] = {
"command-palette--help-text",
"command-palette--highlight",
}
"""
| Class | Description |
| :- | :- |
| `command-palette--help-text` | Targets the help text of a matched command. |
| `command-palette--highlight` | Targets the highlights of a matched command. |
"""
DEFAULT_CSS = """
CommandPalette {
background: $background 30%;
align-horizontal: center;
}
CommandPalette > .command-palette--help-text {
text-style: dim;
background: transparent;
}
CommandPalette > .command-palette--highlight {
text-style: bold reverse;
}
CommandPalette > Vertical {
margin-top: 3;
width: 90%;
height: 100%;
visibility: hidden;
}
CommandPalette #--input {
height: auto;
visibility: visible;
border: hkey $accent;
background: $panel;
}
CommandPalette #--input.--list-visible {
border-bottom: none;
}
CommandPalette #--input Label {
margin-top: 1;
margin-left: 1;
}
CommandPalette #--input Button {
min-width: 7;
margin-right: 1;
}
CommandPalette #--results {
overlay: screen;
height: auto;
}
CommandPalette LoadingIndicator {
height: auto;
visibility: hidden;
background: $panel;
border-bottom: hkey $accent;
}
CommandPalette LoadingIndicator.--visible {
visibility: visible;
}
"""
BINDINGS: ClassVar[list[BindingType]] = [
Binding("ctrl+end, shift+end", "command_list('last')", show=False),
Binding("ctrl+home, shift+home", "command_list('first')", show=False),
Binding("down", "cursor_down", show=False),
Binding("escape", "escape", "Exit the command palette"),
Binding("pagedown", "command_list('page_down')", show=False),
Binding("pageup", "command_list('page_up')", show=False),
Binding("up", "command_list('cursor_up')", 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. |
"""
run_on_select: ClassVar[bool] = True
"""A flag to say if a command should be run when selected by the user.
If `True` then when a user hits `Enter` on a command match in the result
list, or if they click on one with the mouse, the command will be
selected and run. If set to `False` the input will be filled with the
command and then `Enter` should be pressed on the keyboard or the 'go'
button should be pressed.
"""
_list_visible: var[bool] = var(False, init=False)
"""Internal reactive to toggle the visibility of the command list."""
_show_busy: var[bool] = var(False, init=False)
"""Internal reactive to toggle the visibility of the busy indicator."""
_calling_screen: var[Screen[Any] | None] = var(None)
"""A record of the screen that was active when we were called."""
_PALETTE_ID: Final[str] = "--command-palette"
"""The internal ID for the command palette."""
def __init__(self) -> None:
"""Initialise the command palette."""
super().__init__(id=self._PALETTE_ID)
self._selected_command: CommandSourceHit | None = None
"""The command that was selected by the user."""
self._busy_timer: Timer | None = None
"""Keeps track of if there's a busy indication timer in effect."""
@staticmethod
def is_open(app: App) -> bool:
"""Is the command palette current open?
Args:
app: The app to test.
Returns:
`True` if the command palette is currently open, `False` if not.
"""
return app.screen.id == CommandPalette._PALETTE_ID
@property
def _sources(self) -> set[type[CommandSource]]:
"""The currently available command sources.
This is a combination of the command sources defined [in the
application][textual.app.App.COMMAND_SOURCES] and those [defined in
the current screen][textual.screen.Screen.COMMAND_SOURCES].
"""
return (
set()
if self._calling_screen is None
else self.app.COMMAND_SOURCES | self._calling_screen.COMMAND_SOURCES
)
def compose(self) -> ComposeResult:
"""Compose the command palette.
Returns:
The content of the screen.
"""
with Vertical():
with Horizontal(id="--input"):
yield SearchIcon()
yield CommandInput(placeholder="Search...")
if not self.run_on_select:
yield Button("\u25b6")
with Vertical(id="--results"):
yield CommandList()
yield LoadingIndicator()
def _on_click(self, event: Click) -> None:
"""Handle the click event.
Args:
event: The click event.
This method is used to allow clicking on the 'background' as a
method of dismissing the palette.
"""
if self.get_widget_at(event.screen_x, event.screen_y)[0] is self:
self.workers.cancel_all()
self.dismiss()
def _on_mount(self, _: Mount) -> None:
"""Capture the calling screen."""
self._calling_screen = self.app.screen_stack[-2]
def _stop_busy_countdown(self) -> None:
"""Stop any busy countdown that's in effect."""
if self._busy_timer is not None:
self._busy_timer.stop()
self._busy_timer = None
_BUSY_COUNTDOWN: Final[float] = 0.5
"""How many seconds to wait for commands to come in before showing we're busy."""
def _start_busy_countdown(self) -> None:
"""Start a countdown to showing that we're busy searching."""
self._stop_busy_countdown()
def _become_busy() -> None:
if self._list_visible:
self._show_busy = True
self._busy_timer = self._busy_timer = self.set_timer(
self._BUSY_COUNTDOWN, _become_busy
)
def _watch__list_visible(self) -> None:
"""React to the list visible flag being toggled."""
self.query_one(CommandList).set_class(self._list_visible, "--visible")
self.query_one("#--input", Horizontal).set_class(
self._list_visible, "--list-visible"
)
if not self._list_visible:
self._show_busy = False
async def _watch__show_busy(self) -> None:
"""React to the show busy flag being toggled.
This watcher adds or removes a busy indication depending on the
flag's state.
"""
self.query_one(LoadingIndicator).set_class(self._show_busy, "--visible")
self.query_one(CommandList).set_class(self._show_busy, "--populating")
@staticmethod
async def _consume(
source: CommandMatches, commands: Queue[CommandSourceHit]
) -> None:
"""Consume a source of matching commands, feeding the given command queue.
Args:
source: The source to consume.
commands: The command queue to feed.
"""
async for hit in source:
await commands.put(hit)
async def _search_for(
self, search_value: str
) -> AsyncGenerator[CommandSourceHit, bool]:
"""Search for a given search value amongst all of the command sources.
Args:
search_value: The value to search for.
Yields:
The hits made amongst the registered command sources.
"""
# Get the style for highlighted parts of a hit match.
match_style = self._sans_background(
self.get_component_rich_style("command-palette--highlight")
)
# Set up a queue to stream in the command hits from all the sources.
commands: Queue[CommandSourceHit] = Queue()
# Fire up an instance of each command source, inside a task, and
# have them go start looking for matches.
assert self._calling_screen is not None
searches = [
create_task(
self._consume(
source(self._calling_screen, match_style).search(search_value),
commands,
)
)
for source in self._sources
]
# Set up a delay for showing that we're busy.
self._start_busy_countdown()
# Assume the search isn't aborted.
aborted = False
# Now, while there's some task running...
while not aborted and any(not search.done() for search in searches):
try:
# ...briefly wait for something on the stack. If we get
# something yield it up to our caller.
aborted = yield await wait_for(commands.get(), 0.1)
except TimeoutError:
# A timeout is fine. We're just going to go back round again
# and see if anything else has turned up.
pass
except CancelledError:
# A cancelled error means things are being aborted.
aborted = True
else:
# There was no timeout, which means that we managed to yield
# up that command; we're done with it so let the queue know.
commands.task_done()
# Check through all the finished searches, see if any have
# exceptions, and log them. In most other circumstances we'd
# re-raise the exception and quit the application, but the decision
# has been made to find and log exceptions with command sources.
#
# https://github.com/Textualize/textual/pull/3058#discussion_r1310051855
for search in searches:
if search.done():
exception = search.exception()
if exception is not None:
self.log.error(
Traceback.from_exception(
type(exception), exception, exception.__traceback__
)
)
# Having finished the main processing loop, we're not busy any more.
# Anything left in the queue (see next) will fall out more or less
# instantly.
self._stop_busy_countdown()
# If all the sources are pretty fast it could be that we've reached
# this point but the queue isn't empty yet. So here we flush the
# queue of anything left.
while not aborted and not commands.empty():
aborted = yield await commands.get()
# If we were aborted, ensure that all of the searches are cancelled.
if aborted:
for search in searches:
search.cancel()
@staticmethod
def _sans_background(style: Style) -> Style:
"""Returns the given style minus the background color.
Args:
style: The style to remove the color from.
Returns:
The given style, minus its background.
"""
# Here we're pulling out all of the styles *minus* the background.
# This should probably turn into a utility method on Style
# eventually. The reason for this is we want the developer to be
# able to style the help text with a component class, but we want
# the background to always be the background at any given moment in
# the context of an OptionList. At the moment this act of copying
# sans bgcolor seems to be the only way to achieve this.
return Style(
blink2=style.blink2,
blink=style.blink,
bold=style.bold,
color=style.color,
conceal=style.conceal,
dim=style.dim,
encircle=style.encircle,
frame=style.frame,
italic=style.italic,
link=style.link,
overline=style.overline,
reverse=style.reverse,
strike=style.strike,
underline2=style.underline2,
underline=style.underline,
)
def _refresh_command_list(
self, command_list: CommandList, commands: list[Command], clear_current: bool
) -> None:
"""Refresh the command list.
Args:
command_list: The widget that shows the list of commands.
commands: The commands to show in the widget.
clear_current: Should the current content of the list be cleared first?
"""
# For the moment, this is a fairly naive approach to populating the
# command list with a sorted list of commands. Every time we add a
# new one we're nuking the list of options and populating them
# again. If this turns out to not be a great approach, we may try
# and get a lot smarter with this (ideally OptionList will grow a
# method to sort its content in an efficient way; but for now we'll
# go with "worse is better" wisdom).
highlighted = (
command_list.get_option_at_index(command_list.highlighted)
if command_list.highlighted is not None and not clear_current
else None
)
command_list.clear_options().add_options(sorted(commands, reverse=True))
if highlighted is not None:
command_list.highlighted = command_list.get_option_index(highlighted.id)
_RESULT_BATCH_TIME: Final[float] = 0.25
"""How long to wait before adding commands to the command list."""
_NO_MATCHES: Final[str] = "--no-matches"
"""The ID to give the disabled option that shows there were no matches."""
@work(exclusive=True)
async def _gather_commands(self, search_value: str) -> None:
"""Gather up all of the commands that match the search value.
Args:
search_value: The value to search for.
"""
# We'll potentially use the help text style a lot so let's grab it
# the once for use in the loop further down.
help_style = self._sans_background(
self.get_component_rich_style("command-palette--help-text")
)
# The list to hold on to the commands we've gathered from the
# command sources.
gathered_commands: list[Command] = []
# Get a reference to the widget that we're going to drop the
# (display of) commands into.
command_list = self.query_one(CommandList)
# If there's just one option in the list, and it's the item that
# tells the user there were no matches, let's remove that. We're
# starting a new search so we don't want them thinking there's no
# matches already.
if (
command_list.option_count == 1
and command_list.get_option_at_index(0).id == self._NO_MATCHES
):
command_list.remove_option(self._NO_MATCHES)
# Each command will receive a sequential ID. This is going to be
# used to find commands back again when we update the visible list
# and want to settle the selection back on the command it was on.
command_id = 0
# We're going to be checking in on the worker as we loop around, so
# grab a reference to that.
worker = get_current_worker()
# We're ready to show results, ensure the list is visible.
self._list_visible = True
# Go into a busy mode.
self._show_busy = False
# A flag to keep track of if the current content of the command hit
# list needs to be cleared. The initial clear *should* be in
# `_input`, but doing so caused an unsightly "flash" of the list; so
# here we sacrifice "correct" code for a better-looking UI.
clear_current = True
# We're going to batch updates over time, so start off pretending
# we've just done an update.
last_update = monotonic()
# Kick off the search, grabbing the iterator.
search_routine = self._search_for(search_value)
search_results = search_routine.__aiter__()
# We're going to be doing the send/await dance in this code, so we
# need to grab the first yielded command to start things off.
try:
hit = await search_results.__anext__()
except StopAsyncIteration:
# We've been stopped before we've even really got going, likely
# because the user is very quick on the keyboard.
hit = None
while hit:
# Turn the command into something for display, and add it to the
# list of commands that have been gathered so far.
prompt = hit.match_display
if hit.command_help:
prompt = Group(prompt, Text(hit.command_help, style=help_style))
gathered_commands.append(Command(prompt, hit, id=str(command_id)))
# Before we go making any changes to the UI, we do a quick
# double-check that the worker hasn't been cancelled. There's
# little point in doing UI work on a value that isn't needed any
# more.
if worker.is_cancelled:
break
# Having made it this far, it's safe to update the list of
# commands that match the input. Note that we batch up the
# results and only refresh the list once every so often; this
# helps reduce how much UI work needs to be done, but at the
# same time we keep the update frequency often enough so that it
# looks like things are moving along.
now = monotonic()
if (now - last_update) > self._RESULT_BATCH_TIME:
self._refresh_command_list(
command_list, gathered_commands, clear_current
)
clear_current = False
last_update = now
# Bump the ID.
command_id += 1
# Finally, get the available command from the incoming queue;
# note that we send the worker cancelled status down into the
# search method.
try:
hit = await search_routine.asend(worker.is_cancelled)
except StopAsyncIteration:
break
# On the way out, if we're still in play, ensure everything has been
# dropped into the command list.
if not worker.is_cancelled:
self._refresh_command_list(command_list, gathered_commands, clear_current)
# One way or another, we're not busy any more.
self._show_busy = False
# If we didn't get any hits, and we're not cancelled, that would
# mean nothing was found. Give the user positive feedback to that
# effect.
if command_list.option_count == 0 and not worker.is_cancelled:
command_list.add_option(
Option(
Align.center(Text("No matches found")),
disabled=True,
id=self._NO_MATCHES,
)
)
@on(Input.Changed)
def _input(self, event: Input.Changed) -> None:
"""React to input in the command palette.
Args:
event: The input event.
"""
self.workers.cancel_all()
search_value = event.value.strip()
if search_value:
self._gather_commands(search_value)
else:
self._list_visible = False
self.query_one(CommandList).clear_options()
@on(OptionList.OptionSelected)
def _select_command(self, event: OptionList.OptionSelected) -> None:
"""React to a command being selected from the dropdown.
Args:
event: The option selection event.
"""
event.stop()
self.workers.cancel_all()
input = self.query_one(CommandInput)
with self.prevent(Input.Changed):
assert isinstance(event.option, Command)
input.value = str(event.option.command.command_text)
self._selected_command = event.option.command
input.action_end()
self._list_visible = False
self.query_one(CommandList).clear_options()
if self.run_on_select:
self._select_or_command()
@on(Input.Submitted)
@on(Button.Pressed)
def _select_or_command(self) -> None:
"""Depending on context, select or execute a command."""
# If the list is visible, that means we're in "pick a command"
# mode...
if self._list_visible:
# ...so if nothing in the list is highlighted yet...
if self.query_one(CommandList).highlighted is None:
# ...cause the first completion to be highlighted.
self._action_cursor_down()
else:
# The list is visible, something is highlighted, the user
# made a selection "gesture"; let's go select it!
self._action_command_list("select")
else:
# The list isn't visible, which means that if we have a
# command...
if self._selected_command is not None:
# ...we should return it to the parent screen and let it
# decide what to do with it (hopefully it'll run it).
self.workers.cancel_all()
self.dismiss(self._selected_command.command)
def _action_escape(self) -> None:
"""Handle a request to escape out of the command palette."""
if self._list_visible:
self._list_visible = False
else:
self.workers.cancel_all()
self.dismiss()
def _action_command_list(self, action: str) -> None:
"""Pass an action on to the [`CommandList`][textual.command_palette.CommandList].
Args:
action: The action to pass on to the [`CommandList`][textual.command_palette.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")

View File

@@ -49,6 +49,8 @@ from .widgets._toast import ToastRack
if TYPE_CHECKING:
from typing_extensions import Final
from .command_palette import CommandSource
# Unused & ignored imports are needed for the docs to link to these objects:
from .errors import NoWidget # type: ignore # noqa: F401
from .message_pump import MessagePump
@@ -155,6 +157,9 @@ class Screen(Generic[ScreenResultType], Widget):
title: Reactive[str | None] = Reactive(None, compute=False)
"""Screen title to override [the app title][textual.app.App.title]."""
COMMAND_SOURCES: ClassVar[set[type[CommandSource]]] = set()
"""The [command sources](/api/command_palette/) for the screen."""
BINDINGS = [
Binding("tab", "focus_next", "Focus Next", show=False),
Binding("shift+tab", "focus_previous", "Focus Previous", show=False),

View File

@@ -0,0 +1,30 @@
from textual.app import App
from textual.command_palette import (
CommandMatches,
CommandPalette,
CommandSource,
CommandSourceHit,
)
class SimpleSource(CommandSource):
async def search(self, query: str) -> CommandMatches:
def goes_nowhere_does_nothing() -> None:
pass
yield CommandSourceHit(1, query, goes_nowhere_does_nothing, query)
class CommandPaletteApp(App[None]):
COMMAND_SOURCES = {SimpleSource}
def on_mount(self) -> None:
self.action_command_palette()
async def test_clicking_outside_command_palette_closes_it() -> None:
"""Clicking 'outside' the command palette should make it go away."""
async with CommandPaletteApp().run_test() as pilot:
assert len(pilot.app.query(CommandPalette)) == 1
await pilot.click()
assert len(pilot.app.query(CommandPalette)) == 0

View File

@@ -0,0 +1,45 @@
from __future__ import annotations
from textual.app import App, ComposeResult
from textual.command_palette import (
CommandMatches,
CommandPalette,
CommandSource,
CommandSourceHit,
)
from textual.screen import Screen
from textual.widget import Widget
from textual.widgets import Input
class SimpleSource(CommandSource):
environment: set[tuple[App, Screen, Widget | None]] = set()
async def search(self, _: str) -> CommandMatches:
def goes_nowhere_does_nothing() -> None:
pass
SimpleSource.environment.add((self.app, self.screen, self.focused))
yield CommandSourceHit(1, "Hit", goes_nowhere_does_nothing, "Hit")
class CommandPaletteApp(App[None]):
COMMAND_SOURCES = {SimpleSource}
def compose(self) -> ComposeResult:
yield Input()
def on_mount(self) -> None:
self.action_command_palette()
async def test_command_source_environment() -> None:
"""The command source should see the app and default screen."""
async with CommandPaletteApp().run_test() as pilot:
base_screen = pilot.app.query_one(CommandPalette)._calling_screen
assert base_screen is not None
await pilot.press(*"test")
assert len(SimpleSource.environment) == 1
assert SimpleSource.environment == {
(pilot.app, base_screen, base_screen.query_one(Input))
}

View File

@@ -0,0 +1,102 @@
from textual.app import App
from textual.command_palette import (
CommandMatches,
CommandPalette,
CommandSource,
CommandSourceHit,
)
from textual.screen import Screen
async def test_sources_with_no_known_screen() -> None:
"""A command palette with no known screen should have an empty source set."""
assert CommandPalette()._sources == set()
class ExampleCommandSource(CommandSource):
async def search(self, _: str) -> CommandMatches:
def goes_nowhere_does_nothing() -> None:
pass
yield CommandSourceHit(1, "Hit", goes_nowhere_does_nothing, "Hit")
class AppWithActiveCommandPalette(App[None]):
def on_mount(self) -> None:
self.action_command_palette()
class AppWithNoSources(AppWithActiveCommandPalette):
pass
async def test_no_app_command_sources() -> None:
"""An app with no sources declared should work fine."""
async with AppWithNoSources().run_test() as pilot:
assert pilot.app.query_one(CommandPalette)._sources == App.COMMAND_SOURCES
class AppWithSources(AppWithActiveCommandPalette):
COMMAND_SOURCES = {ExampleCommandSource}
async def test_app_command_sources() -> None:
"""Command sources declared on an app should be in the command palette."""
async with AppWithSources().run_test() as pilot:
assert (
pilot.app.query_one(CommandPalette)._sources
== AppWithSources.COMMAND_SOURCES
)
class AppWithInitialScreen(App[None]):
def __init__(self, screen: Screen) -> None:
super().__init__()
self._test_screen = screen
def on_mount(self) -> None:
self.push_screen(self._test_screen)
class ScreenWithNoSources(Screen[None]):
def on_mount(self) -> None:
self.app.action_command_palette()
async def test_no_screen_command_sources() -> None:
"""An app with a screen with no sources declared should work fine."""
async with AppWithInitialScreen(ScreenWithNoSources()).run_test() as pilot:
assert pilot.app.query_one(CommandPalette)._sources == App.COMMAND_SOURCES
class ScreenWithSources(ScreenWithNoSources):
COMMAND_SOURCES = {ExampleCommandSource}
async def test_screen_command_sources() -> None:
"""Command sources declared on a screen should be in the command palette."""
async with AppWithInitialScreen(ScreenWithSources()).run_test() as pilot:
assert (
pilot.app.query_one(CommandPalette)._sources
== App.COMMAND_SOURCES | ScreenWithSources.COMMAND_SOURCES
)
class AnotherCommandSource(ExampleCommandSource):
pass
class CombinedSourceApp(App[None]):
COMMAND_SOURCES = {AnotherCommandSource}
def on_mount(self) -> None:
self.push_screen(ScreenWithSources())
async def test_app_and_screen_command_sources_combine() -> None:
"""If an app and the screen have command sources they should combine."""
async with CombinedSourceApp().run_test() as pilot:
assert (
pilot.app.query_one(CommandPalette)._sources
== CombinedSourceApp.COMMAND_SOURCES | ScreenWithSources.COMMAND_SOURCES
)

View File

@@ -0,0 +1,55 @@
from textual.app import App
from textual.command_palette import (
CommandMatches,
CommandPalette,
CommandSource,
CommandSourceHit,
)
class SimpleSource(CommandSource):
async def search(self, query: str) -> CommandMatches:
def goes_nowhere_does_nothing() -> None:
pass
yield CommandSourceHit(1, query, goes_nowhere_does_nothing, query)
class CommandPaletteApp(App[None]):
COMMAND_SOURCES = {SimpleSource}
def on_mount(self) -> None:
self.action_command_palette()
async def test_escape_closes_when_no_list_visible() -> None:
"""Pressing escape when no list is visible should close the command palette."""
async with CommandPaletteApp().run_test() as pilot:
assert len(pilot.app.query(CommandPalette)) == 1
await pilot.press("escape")
assert len(pilot.app.query(CommandPalette)) == 0
async def test_escape_does_not_close_when_list_visible() -> None:
"""Pressing escape when a hit list is visible should not close the command palette."""
async with CommandPaletteApp().run_test() as pilot:
assert len(pilot.app.query(CommandPalette)) == 1
await pilot.press("a")
await pilot.press("escape")
assert len(pilot.app.query(CommandPalette)) == 1
await pilot.press("escape")
assert len(pilot.app.query(CommandPalette)) == 0
async def test_down_arrow_should_undo_closing_of_list_via_escape() -> None:
"""Down arrow should reopen the hit list if escape closed it before."""
async with CommandPaletteApp().run_test() as pilot:
assert len(pilot.app.query(CommandPalette)) == 1
await pilot.press("a")
await pilot.press("escape")
assert len(pilot.app.query(CommandPalette)) == 1
await pilot.press("down")
await pilot.press("escape")
assert len(pilot.app.query(CommandPalette)) == 1
await pilot.press("escape")
assert len(pilot.app.query(CommandPalette)) == 0

View File

@@ -0,0 +1,72 @@
from textual.app import App
from textual.command_palette import (
CommandList,
CommandMatches,
CommandPalette,
CommandSource,
CommandSourceHit,
)
class SimpleSource(CommandSource):
async def search(self, query: str) -> CommandMatches:
def goes_nowhere_does_nothing() -> None:
pass
for _ in range(100):
yield CommandSourceHit(1, query, goes_nowhere_does_nothing, query)
class CommandPaletteApp(App[None]):
COMMAND_SOURCES = {SimpleSource}
def on_mount(self) -> None:
self.action_command_palette()
async def test_initial_list_no_highlight() -> None:
"""When the list initially appears, nothing will be highlighted."""
async with CommandPaletteApp().run_test() as pilot:
assert len(pilot.app.query(CommandPalette)) == 1
assert pilot.app.query_one(CommandList).visible is False
await pilot.press("a")
assert pilot.app.query_one(CommandList).visible is True
assert pilot.app.query_one(CommandList).highlighted is None
async def test_down_arrow_selects_an_item() -> None:
"""Typing in a search value then pressing down should select a command."""
async with CommandPaletteApp().run_test() as pilot:
assert len(pilot.app.query(CommandPalette)) == 1
assert pilot.app.query_one(CommandList).visible is False
await pilot.press("a")
assert pilot.app.query_one(CommandList).visible is True
assert pilot.app.query_one(CommandList).highlighted is None
await pilot.press("down")
assert pilot.app.query_one(CommandList).highlighted is not None
async def test_enter_selects_an_item() -> None:
"""Typing in a search value then pressing enter should select a command."""
async with CommandPaletteApp().run_test() as pilot:
assert len(pilot.app.query(CommandPalette)) == 1
assert pilot.app.query_one(CommandList).visible is False
await pilot.press("a")
assert pilot.app.query_one(CommandList).visible is True
assert pilot.app.query_one(CommandList).highlighted is None
await pilot.press("enter")
assert pilot.app.query_one(CommandList).highlighted is not None
async def test_selection_of_command_closes_command_palette() -> None:
"""Selecting a command from the list should close the list."""
async with CommandPaletteApp().run_test() as pilot:
assert len(pilot.app.query(CommandPalette)) == 1
assert pilot.app.query_one(CommandList).visible is False
await pilot.press("a")
assert pilot.app.query_one(CommandList).visible is True
assert pilot.app.query_one(CommandList).highlighted is None
await pilot.press("enter")
assert pilot.app.query_one(CommandList).highlighted is not None
await pilot.press("enter")
assert len(pilot.app.query(CommandPalette)) == 0

View File

@@ -0,0 +1,25 @@
from textual.app import App
from textual.command_palette import CommandPalette
from textual.widgets import OptionList
class CommandPaletteApp(App[None]):
COMMAND_SOURCES = set()
def on_mount(self) -> None:
self.action_command_palette()
async def test_no_results() -> None:
"""Receiving no results from a search for a command should not be a problem."""
async with CommandPaletteApp().run_test() as pilot:
assert len(pilot.app.query(CommandPalette)) == 1
results = pilot.app.screen.query_one(OptionList)
assert results.visible is False
assert results.option_count == 0
await pilot.press("a")
await pilot.pause()
assert results.visible is True
assert results.option_count == 1
assert "No matches found" in str(results.get_option_at_index(0).prompt)
assert results.get_option_at_index(0).disabled is True

View File

@@ -0,0 +1,72 @@
from functools import partial
from textual.app import App
from textual.command_palette import (
CommandMatches,
CommandPalette,
CommandSource,
CommandSourceHit,
)
from textual.widgets import Input
class SimpleSource(CommandSource):
async def search(self, _: str) -> CommandMatches:
def goes_nowhere_does_nothing(selection: int) -> None:
assert isinstance(self.app, CommandPaletteRunOnSelectApp)
self.app.selection = selection
for n in range(100):
yield CommandSourceHit(
n + 1 / 100,
str(n),
partial(goes_nowhere_does_nothing, n),
str(n),
f"This is help for {n}",
)
class CommandPaletteRunOnSelectApp(App[None]):
COMMAND_SOURCES = {SimpleSource}
def __init__(self) -> None:
super().__init__()
self.selection: int | None = None
async def test_with_run_on_select_on() -> None:
"""With run on select on, the callable should be instantly run."""
async with CommandPaletteRunOnSelectApp().run_test() as pilot:
save = CommandPalette.run_on_select
CommandPalette.run_on_select = True
assert isinstance(pilot.app, CommandPaletteRunOnSelectApp)
pilot.app.action_command_palette()
await pilot.press("0")
await pilot.app.query_one(CommandPalette).workers.wait_for_complete()
await pilot.press("down")
await pilot.press("enter")
assert pilot.app.selection is not None
CommandPalette.run_on_select = save
class CommandPaletteDoNotRunOnSelectApp(CommandPaletteRunOnSelectApp):
def __init__(self) -> None:
super().__init__()
async def test_with_run_on_select_off() -> None:
"""With run on select off, the callable should not be instantly run."""
async with CommandPaletteDoNotRunOnSelectApp().run_test() as pilot:
save = CommandPalette.run_on_select
CommandPalette.run_on_select = False
assert isinstance(pilot.app, CommandPaletteDoNotRunOnSelectApp)
pilot.app.action_command_palette()
await pilot.press("0")
await pilot.app.query_one(CommandPalette).workers.wait_for_complete()
await pilot.press("down")
await pilot.press("enter")
assert pilot.app.selection is None
assert pilot.app.query_one(Input).value != ""
await pilot.press("enter")
assert pilot.app.selection is not None
CommandPalette.run_on_select = save

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,25 @@
from textual.app import App
from textual.command_palette import CommandSource, CommandMatches, CommandSourceHit
class TestSource(CommandSource):
def goes_nowhere_does_nothing(self) -> None:
pass
async def search(self, query: str) -> CommandMatches:
matcher = self.matcher(query)
for n in range(10):
command = f"This is a test of this code {n}"
yield CommandSourceHit(
n/10, matcher.highlight(command), self.goes_nowhere_does_nothing, command
)
class CommandPaletteApp(App[None]):
COMMAND_SOURCES = {TestSource}
def on_mount(self) -> None:
self.action_command_palette()
if __name__ == "__main__":
CommandPaletteApp().run()

View File

@@ -599,6 +599,16 @@ def test_tooltips_in_compound_widgets(snap_compare):
assert snap_compare(SNAPSHOT_APPS_DIR / "tooltips.py", run_before=run_before)
def test_command_palette(snap_compare) -> None:
from textual.command_palette import CommandPalette
async def run_before(pilot) -> None:
await pilot.press("ctrl+@")
await pilot.press("A")
await pilot.app.query_one(CommandPalette).workers.wait_for_complete()
assert snap_compare(SNAPSHOT_APPS_DIR / "command_palette.py", run_before=run_before)
# --- textual-dev library preview tests ---

View File

@@ -39,7 +39,7 @@ class NoBindings(App[None]):
async def test_just_app_no_bindings() -> None:
"""An app with no bindings should have no bindings, other than ctrl+c."""
async with NoBindings().run_test() as pilot:
assert list(pilot.app._bindings.keys.keys()) == ["ctrl+c"]
assert list(pilot.app._bindings.keys.keys()) == ["ctrl+c", "ctrl+@"]
assert pilot.app._bindings.get_key("ctrl+c").priority is True
@@ -60,7 +60,9 @@ 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.keys.keys()) == sorted(["ctrl+c", "a"])
assert sorted(pilot.app._bindings.keys.keys()) == sorted(
["ctrl+c", "ctrl+@", "a"]
)
assert pilot.app._bindings.get_key("ctrl+c").priority is True
assert pilot.app._bindings.get_key("a").priority is True
@@ -82,7 +84,9 @@ 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.keys.keys()) == sorted(["ctrl+c", "a"])
assert sorted(pilot.app._bindings.keys.keys()) == sorted(
["ctrl+c", "ctrl+@", "a"]
)
assert pilot.app._bindings.get_key("ctrl+c").priority is True
assert pilot.app._bindings.get_key("a").priority is False

View File

@@ -1,3 +1,4 @@
from rich.style import Style
from rich.text import Span
from textual._fuzzy import Matcher
@@ -28,13 +29,12 @@ def test_highlight():
matcher = Matcher("foo.bar")
spans = matcher.highlight("foo/egg.bar").spans
print(spans)
assert spans == [
Span(0, 1, "bold"),
Span(1, 2, "bold"),
Span(2, 3, "bold"),
Span(7, 8, "bold"),
Span(8, 9, "bold"),
Span(9, 10, "bold"),
Span(10, 11, "bold"),
Span(0, 1, Style(reverse=True)),
Span(1, 2, Style(reverse=True)),
Span(2, 3, Style(reverse=True)),
Span(7, 8, Style(reverse=True)),
Span(8, 9, Style(reverse=True)),
Span(9, 10, Style(reverse=True)),
Span(10, 11, Style(reverse=True)),
]