Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

added system commands #4920

Merged
merged 14 commits into from
Aug 22, 2024
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,15 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Added `App.get_key_display` https://github.com/Textualize/textual/pull/4890
- Added `DOMNode.BINDING_GROUP` https://github.com/Textualize/textual/pull/4906
- Added `DOMNode.HELP` classvar which contains Markdown help to be shown in the help panel https://github.com/Textualize/textual/pull/4915
- Added `App.get_system_commands` https://github.com/Textualize/textual/pull/4920

### Changed

- Removed caps_lock and num_lock modifiers https://github.com/Textualize/textual/pull/4861
- Keys such as escape and space are now displayed in lower case in footer https://github.com/Textualize/textual/pull/4876
- Changed default command palette binding to `ctrl+p` https://github.com/Textualize/textual/pull/4867
- Removed `ctrl_to_caret` and `upper_case_keys` from Footer. These can be implemented in `App.get_key_display`.
- Renamed `SystemCommands` to `SystemCommandsProvider` https://github.com/Textualize/textual/pull/4920

### Fixed

Expand Down
69 changes: 8 additions & 61 deletions docs/examples/guide/command_palette/command01.py
Original file line number Diff line number Diff line change
@@ -1,68 +1,15 @@
from __future__ import annotations
from textual.app import App, SystemCommandsResult
from textual.screen import Screen

from functools import partial
from pathlib import Path

from textual.app import App, ComposeResult
from textual.command import Hit, Hits, Provider
from textual.containers import VerticalScroll
from textual.widgets import Static
class BellCommandApp(App):
"""An app with a 'bell' command."""


class PythonFileCommands(Provider):
"""A command provider to open a Python file in the current working directory."""

def read_files(self) -> list[Path]:
"""Get a list of Python files in the current working directory."""
return list(Path("./").glob("*.py"))

async def startup(self) -> None: # (1)!
"""Called once when the command palette is opened, prior to searching."""
worker = self.app.run_worker(self.read_files, thread=True)
self.python_paths = await worker.wait()

async def search(self, query: str) -> Hits: # (2)!
"""Search for Python files."""
matcher = self.matcher(query) # (3)!

app = self.app
assert isinstance(app, ViewerApp)

for path in self.python_paths:
command = f"open {str(path)}"
score = matcher.match(command) # (4)!
if score > 0:
yield Hit(
score,
matcher.highlight(command), # (5)!
partial(app.open_file, path),
help="Open this file in the viewer",
)


class ViewerApp(App):
"""Demonstrate a command source."""

COMMANDS = App.COMMANDS | {PythonFileCommands} # (6)!

def compose(self) -> ComposeResult:
with VerticalScroll():
yield Static(id="code", expand=True)

def open_file(self, path: Path) -> None:
"""Open and display a file with syntax highlighting."""
from rich.syntax import Syntax

syntax = Syntax.from_path(
str(path),
line_numbers=True,
word_wrap=False,
indent_guides=True,
theme="github-dark",
)
self.query_one("#code", Static).update(syntax)
def get_system_commands(self, screen: Screen) -> SystemCommandsResult:
yield from super().get_system_commands(screen) # (1)!
yield ("Bell", "Ring the bell", self.bell) # (2)!


if __name__ == "__main__":
app = ViewerApp()
app = BellCommandApp()
app.run()
68 changes: 68 additions & 0 deletions docs/examples/guide/command_palette/command02.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
from __future__ import annotations

from functools import partial
from pathlib import Path

from textual.app import App, ComposeResult
from textual.command import Hit, Hits, Provider
from textual.containers import VerticalScroll
from textual.widgets import Static


class PythonFileCommands(Provider):
"""A command provider to open a Python file in the current working directory."""

def read_files(self) -> list[Path]:
"""Get a list of Python files in the current working directory."""
return list(Path("./").glob("*.py"))

async def startup(self) -> None: # (1)!
"""Called once when the command palette is opened, prior to searching."""
worker = self.app.run_worker(self.read_files, thread=True)
self.python_paths = await worker.wait()

async def search(self, query: str) -> Hits: # (2)!
"""Search for Python files."""
matcher = self.matcher(query) # (3)!

app = self.app
assert isinstance(app, ViewerApp)

for path in self.python_paths:
command = f"open {str(path)}"
score = matcher.match(command) # (4)!
if score > 0:
yield Hit(
score,
matcher.highlight(command), # (5)!
partial(app.open_file, path),
help="Open this file in the viewer",
)


class ViewerApp(App):
"""Demonstrate a command source."""

COMMANDS = App.COMMANDS | {PythonFileCommands} # (6)!

def compose(self) -> ComposeResult:
with VerticalScroll():
yield Static(id="code", expand=True)

def open_file(self, path: Path) -> None:
"""Open and display a file with syntax highlighting."""
from rich.syntax import Syntax

syntax = Syntax.from_path(
str(path),
line_numbers=True,
word_wrap=False,
indent_guides=True,
theme="github-dark",
)
self.query_one("#code", Static).update(syntax)


if __name__ == "__main__":
app = ViewerApp()
app.run()
38 changes: 28 additions & 10 deletions docs/guide/command_palette.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,36 @@ This scheme allows the user to quickly get to a particular command with a minimu
```{.textual path="docs/examples/guide/command_palette/command01.py" press="ctrl+p,t,d"}
```

## System commands

Textual apps have a number of *system* commands enabled by default.
These are declared in the [`App.get_system_commands`][textual.app.App.get_system_commands] method.
You can implement this method in your App class to add more commands.

## Default commands
To declare a command, define a `get_system_commands` method on your App.
Textual will call this method with the screen that was active when the user summoned the command palette.
You can add a command by yielding a tuple of `(TITLE, HELP TEXT, CALLABLE)`.
The `TITLE` and `HELP TEXT` values are shown in the command palette.
If the user selects that command, then Textual will invoke `CALLABLE`.

Textual apps have the following commands enabled by default:
Here's how we would add a command to ring the terminal bell (a super useful piece of functionality):

- `"Toggle light/dark mode"`
This will toggle between light and dark mode, by setting `App.dark` to either `True` or `False`.
- `"Quit the application"`
Quits the application. The equivalent of pressing ++ctrl+C++.
- `"Play the bell"`
Plays the terminal bell, by calling [`App.bell`][textual.app.App.bell].
=== "command01.py"

```python title="command01.py" hl_lines="18-24 29"
--8<-- "docs/examples/guide/command_palette/command01.py"
```

1. Adds the default commands from the base class.
2. Adds a new command.

=== "Output"

```{.textual path="docs/examples/guide/command_palette/command01.py" press="ctrl+p"}
```

This is a straightforward way of adding commands to your app.
For more advanced integrations you can implement your own *command providers*.


## Command providers
Expand All @@ -57,8 +75,8 @@ The following example will display a blank screen initially, but if you bring up
If you are running that example from the repository, you may want to add some additional Python files to see how the examples works with multiple files.


```python title="command01.py" hl_lines="12-40 46"
--8<-- "docs/examples/guide/command_palette/command01.py"
```python title="command02.py" hl_lines="12-40 46"
--8<-- "docs/examples/guide/command_palette/command02.py"
```

1. This method is called when the command palette is first opened.
Expand Down
2 changes: 0 additions & 2 deletions docs/widgets/footer.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,6 @@ widget. Notice how the `Footer` automatically displays the keybinding.

| Name | Type | Default | Description |
| ---------------------- | ------ | ------- | ------------------------------------------------------------------------------------------ |
| `upper_case_keys` | `bool` | `False` | Display the keys in upper case. |
| `ctrl_to_caret` | `bool` | `True` | Replace "ctrl+" with "^" to denote a key that requires holding ++CTRL++ |
| `compact` | `bool` | `False` | Display a more compact footer. |
| `show_command_palette` | `bool` | `True` | Display the key to invoke the command palette (show on the right hand side of the footer). |

Expand Down
69 changes: 64 additions & 5 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@
from .filter import LineFilter
from .message import Message
from .pilot import Pilot
from .system_commands import SystemCommands
from .system_commands import SystemCommandsProvider
from .widget import MountError # type: ignore # noqa: F401

WINDOWS = sys.platform == "win32"
Expand Down Expand Up @@ -171,16 +171,22 @@
)
"""Signature for valid callbacks that can be used to control apps."""

CommandCallback: TypeAlias = "Callable[[], Awaitable[Any]] | Callable[[], Any]"
"""Signature for callbacks used in [`get_system_commands`][textual.app.App.get_system_commands]"""

def get_system_commands() -> type[SystemCommands]:
SystemCommandsResult: TypeAlias = "Iterable[tuple[str, str, CommandCallback]]"
"""The return type of App.get_system_commands"""


def get_system_commands_provider() -> type[SystemCommandsProvider]:
"""Callable to lazy load the system commands.

Returns:
System commands class.
"""
from .system_commands import SystemCommands
from .system_commands import SystemCommandsProvider

return SystemCommands
return SystemCommandsProvider


class AppError(Exception):
Expand Down Expand Up @@ -359,7 +365,7 @@ class MyApp(App[None]):
"""Default number of seconds to show notifications before removing them."""

COMMANDS: ClassVar[set[type[Provider] | Callable[[], type[Provider]]]] = {
get_system_commands
get_system_commands_provider
}
"""Command providers used by the [command palette](/guide/command_palette).

Expand Down Expand Up @@ -922,6 +928,59 @@ def active_bindings(self) -> dict[str, ActiveBinding]:
"""
return self.screen.active_bindings

def get_system_commands(self, screen: Screen) -> SystemCommandsResult:
"""A generator of system commands used in the command palette.

Args:
screen: The screen where the command palette was invoked from.

Implement this method in your App subclass if you want to add custom commands.
Here is an example:

```python
def get_system_commands(self):
yield from super().get_system_commands()
yield ("Bell", "Ring the bell", self.bell)
```

!!! note
Requires that [`SystemCommandsProvider`][textual.system_commands.SystemCommandsProvider] is in `App.COMMANDS` class variable.

Yields:
tuples of (TITLE, HELP TEXT, CALLBACK)
willmcgugan marked this conversation as resolved.
Show resolved Hide resolved
"""
if self.dark:
yield (
"Light mode",
"Switch to a light background",
self.action_toggle_dark,
)
else:
yield (
"Dark mode",
"Switch to a dark background",
self.action_toggle_dark,
)

yield (
"Quit the application",
"Quit the application as soon as possible",
self.action_quit,
)

if screen.query("HelpPanel"):
yield (
"Hide keys and help panel",
"Hide the keys and widget help panel",
self.action_hide_help_panel,
)
else:
yield (
"Show keys and help panel",
"Show help for the focused widget and a summary of available keys",
self.action_show_help_panel,
)

def get_default_screen(self) -> Screen:
"""Get the default screen.

Expand Down
Loading
Loading