mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
@@ -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
135
docs/api/command_palette.md
Normal 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
|
||||
1
docs/api/fuzzy_matcher.md
Normal file
1
docs/api/fuzzy_matcher.md
Normal file
@@ -0,0 +1 @@
|
||||
::: textual._fuzzy
|
||||
1
docs/api/system_commands_source.md
Normal file
1
docs/api/system_commands_source.md
Normal file
@@ -0,0 +1 @@
|
||||
::: textual._system_commands_source
|
||||
@@ -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(
|
||||
|
||||
@@ -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
541
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
56
src/textual/_system_commands_source.py
Normal file
56
src/textual/_system_commands_source.py
Normal 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,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
892
src/textual/command_palette.py
Normal file
892
src/textual/command_palette.py
Normal 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")
|
||||
@@ -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),
|
||||
|
||||
30
tests/command_palette/test_click_away.py
Normal file
30
tests/command_palette/test_click_away.py
Normal 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
|
||||
45
tests/command_palette/test_command_source_environment.py
Normal file
45
tests/command_palette/test_command_source_environment.py
Normal 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))
|
||||
}
|
||||
102
tests/command_palette/test_declare_sources.py
Normal file
102
tests/command_palette/test_declare_sources.py
Normal 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
|
||||
)
|
||||
55
tests/command_palette/test_escaping.py
Normal file
55
tests/command_palette/test_escaping.py
Normal 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
|
||||
72
tests/command_palette/test_interaction.py
Normal file
72
tests/command_palette/test_interaction.py
Normal 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
|
||||
25
tests/command_palette/test_no_results.py
Normal file
25
tests/command_palette/test_no_results.py
Normal 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
|
||||
72
tests/command_palette/test_run_on_select.py
Normal file
72
tests/command_palette/test_run_on_select.py
Normal 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
25
tests/snapshot_tests/snapshot_apps/command_palette.py
Normal file
25
tests/snapshot_tests/snapshot_apps/command_palette.py
Normal 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()
|
||||
@@ -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 ---
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user