diff --git a/CHANGELOG.md b/CHANGELOG.md index d51fca7671..2331695455 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ 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 @@ -30,6 +31,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - 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 - Breaking change: Removed ClassicFooter (please use new Footer widget) https://github.com/Textualize/textual/pull/4921 ### Fixed diff --git a/docs/examples/guide/command_palette/command01.py b/docs/examples/guide/command_palette/command01.py index 2e0a9f82d6..54cbb2cc0b 100644 --- a/docs/examples/guide/command_palette/command01.py +++ b/docs/examples/guide/command_palette/command01.py @@ -1,68 +1,17 @@ -from __future__ import annotations +from typing import Iterable -from functools import partial -from pathlib import Path +from textual.app import App, SystemCommand +from textual.screen import Screen -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) -> Iterable[SystemCommand]: + yield from super().get_system_commands(screen) # (1)! + yield SystemCommand("Bell", "Ring the bell", self.bell) # (2)! if __name__ == "__main__": - app = ViewerApp() + app = BellCommandApp() app.run() diff --git a/docs/examples/guide/command_palette/command02.py b/docs/examples/guide/command_palette/command02.py new file mode 100644 index 0000000000..2e0a9f82d6 --- /dev/null +++ b/docs/examples/guide/command_palette/command02.py @@ -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() diff --git a/docs/guide/command_palette.md b/docs/guide/command_palette.md index a8a474c719..85b30c483b 100644 --- a/docs/guide/command_palette.md +++ b/docs/guide/command_palette.md @@ -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. -Textual apps have the following commands enabled by default: +You can add a command by yielding a [`SystemCommand`][textual.app.SystemCommand] object which contains `title` and `help` text to be shown in the command palette, and `callback` which is a callable to run when the user selects the command. +Additionally, there is a `discover` boolean which when `True` (the default) shows the command even if the search import is empty. When set to `False`, the command will show only when there is input. -- `"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]. +Here's how we would add a command to ring the terminal bell (a super useful piece of functionality): + +=== "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 @@ -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. diff --git a/docs/widgets/footer.md b/docs/widgets/footer.md index 2435b6eb34..7bbc7c2ed6 100644 --- a/docs/widgets/footer.md +++ b/docs/widgets/footer.md @@ -29,8 +29,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). | diff --git a/src/textual/app.py b/src/textual/app.py index 1f1a32b824..775280b152 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -38,6 +38,7 @@ Generic, Iterable, Iterator, + NamedTuple, Sequence, Type, TypeVar, @@ -128,7 +129,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" @@ -171,16 +172,32 @@ ) """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]: + +class SystemCommand(NamedTuple): + """Defines a system command used in the command palette (yielded from [`get_system_commands`][textual.app.App.get_system_commands]).""" + + title: str + """The title of the command (used in search).""" + help: str + """Additional help text, shown under the title.""" + callback: CommandCallback + """A callback to invoke when the command is selected.""" + discover: bool = True + """Should the command show when the search is empty?""" + + +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): @@ -359,7 +376,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). @@ -922,6 +939,59 @@ def active_bindings(self) -> dict[str, ActiveBinding]: """ return self.screen.active_bindings + def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]: + """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) -> Iterable[SystemCommand]: + yield from super().get_system_commands() + yield SystemCommand("Bell", "Ring the bell", self.bell) + ``` + + !!! note + Requires that [`SystemCommandsProvider`][textual.system_commands.SystemCommandsProvider] is in `App.COMMANDS` class variable. + + Yields: + [SystemCommand][textual.app.SystemCommand] instances. + """ + if self.dark: + yield SystemCommand( + "Light mode", + "Switch to a light background", + self.action_toggle_dark, + ) + else: + yield SystemCommand( + "Dark mode", + "Switch to a dark background", + self.action_toggle_dark, + ) + + yield SystemCommand( + "Quit the application", + "Quit the application as soon as possible", + self.action_quit, + ) + + if screen.query("HelpPanel"): + yield SystemCommand( + "Hide keys and help panel", + "Hide the keys and widget help panel", + self.action_hide_help_panel, + ) + else: + yield SystemCommand( + "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. diff --git a/src/textual/system_commands.py b/src/textual/system_commands.py index d2ad79c5b6..9969fb0d9b 100644 --- a/src/textual/system_commands.py +++ b/src/textual/system_commands.py @@ -13,52 +13,24 @@ from __future__ import annotations -from typing import Iterable +from typing import TYPE_CHECKING from .command import DiscoveryHit, Hit, Hits, Provider -from .types import IgnoreReturnCallbackType +if TYPE_CHECKING: + from .app import SystemCommandsResult -class SystemCommands(Provider): + +class SystemCommandsProvider(Provider): """A [source][textual.command.Provider] of command palette commands that run app-wide tasks. Used by default in [`App.COMMANDS`][textual.app.App.COMMANDS]. """ @property - def _system_commands(self) -> Iterable[tuple[str, IgnoreReturnCallbackType, str]]: + def _system_commands(self) -> SystemCommandsResult: """The system commands to reveal to the command palette.""" - if self.app.dark: - yield ( - "Light mode", - self.app.action_toggle_dark, - "Switch to a light background", - ) - else: - yield ( - "Dark mode", - self.app.action_toggle_dark, - "Switch to a dark background", - ) - - yield ( - "Quit the application", - self.app.action_quit, - "Quit the application as soon as possible", - ) - - if self.screen.query("HelpPanel"): - yield ( - "Hide keys and help panel", - self.app.action_hide_help_panel, - "Hide the keys and widget help panel", - ) - else: - yield ( - "Show keys and help panel", - self.app.action_show_help_panel, - "Show help for the focused widget and a summary of available keys", - ) + yield from self.app.get_system_commands(self.screen) async def discover(self) -> Hits: """Handle a request for the discovery commands for this provider. @@ -67,12 +39,13 @@ async def discover(self) -> Hits: Commands that can be discovered. """ commands = sorted(self._system_commands, key=lambda command: command[0]) - for name, runnable, help_text in commands: - yield DiscoveryHit( - name, - runnable, - help=help_text, - ) + for name, help_text, callback, discover in commands: + if discover: + yield DiscoveryHit( + name, + callback, + help=help_text, + ) async def search(self, query: str) -> Hits: """Handle a request to search for system commands that match the query. @@ -89,11 +62,11 @@ async def search(self, query: str) -> Hits: # Loop over all applicable commands, find those that match and offer # them up to the command palette. - for name, runnable, help_text in self._system_commands: + for name, help_text, callback, *_ in self._system_commands: if (match := matcher.match(name)) > 0: yield Hit( match, matcher.highlight(name), - runnable, + callback, help=help_text, ) diff --git a/tests/command_palette/test_declare_sources.py b/tests/command_palette/test_declare_sources.py index 340a9b1666..e7691476be 100644 --- a/tests/command_palette/test_declare_sources.py +++ b/tests/command_palette/test_declare_sources.py @@ -1,7 +1,7 @@ from textual.app import App from textual.command import CommandPalette, Hit, Hits, Provider from textual.screen import Screen -from textual.system_commands import SystemCommands +from textual.system_commands import SystemCommandsProvider async def test_sources_with_no_known_screen() -> None: @@ -30,7 +30,7 @@ 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 isinstance(pilot.app.screen, CommandPalette) - assert pilot.app.screen._provider_classes == {SystemCommands} + assert pilot.app.screen._provider_classes == {SystemCommandsProvider} class AppWithSources(AppWithActiveCommandPalette): @@ -62,7 +62,7 @@ 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 isinstance(pilot.app.screen, CommandPalette) - assert pilot.app.screen._provider_classes == {SystemCommands} + assert pilot.app.screen._provider_classes == {SystemCommandsProvider} class ScreenWithSources(ScreenWithNoSources): @@ -74,7 +74,7 @@ async def test_screen_command_sources() -> None: async with AppWithInitialScreen(ScreenWithSources()).run_test() as pilot: assert isinstance(pilot.app.screen, CommandPalette) assert pilot.app.screen._provider_classes == { - SystemCommands, + SystemCommandsProvider, ExampleCommandSource, } diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_system_commands.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_system_commands.svg new file mode 100644 index 0000000000..3968eb38ff --- /dev/null +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_system_commands.svg @@ -0,0 +1,185 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SimpleApp + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + +🔎Search for commands… + + +  Light mode                                                                                         +Switch to a light background +  Quit the application                                                                               +Quit the application as soon as possible +  Show keys and help panel                                                                           +Show help for the focused widget and a summary of available keys +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + + + + + diff --git a/tests/snapshot_tests/snapshot_apps/help_panel.py b/tests/snapshot_tests/snapshot_apps/help_panel.py deleted file mode 100644 index 05512865be..0000000000 --- a/tests/snapshot_tests/snapshot_apps/help_panel.py +++ /dev/null @@ -1,12 +0,0 @@ -from textual.app import App, ComposeResult -from textual.widgets import Input - - -class HelpPanelApp(App): - def compose(self) -> ComposeResult: - yield Input() - - -if __name__ == "__main__": - app = HelpPanelApp() - app.run() diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 6d48d756b1..89e893c0a1 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -3,7 +3,7 @@ import pytest from tests.snapshot_tests.language_snippets import SNIPPETS -from textual.app import App +from textual.app import App, ComposeResult from textual.pilot import Pilot from textual.widgets import Button, Input, RichLog, TextArea from textual.widgets.text_area import BUILTIN_LANGUAGES, Selection, TextAreaTheme @@ -1450,15 +1450,38 @@ def test_split(snap_compare): assert snap_compare(SNAPSHOT_APPS_DIR / "split.py", terminal_size=(100, 30)) -def test_help_panel(snap_compare): - """Test help panel.""" +def test_system_commands(snap_compare): + """The system commands should appear in the command palette.""" + + class SimpleApp(App): + def compose(self) -> ComposeResult: + yield Input() + assert snap_compare( - SNAPSHOT_APPS_DIR / "help_panel.py", + SimpleApp(), terminal_size=(100, 30), - press=["ctrl+p", *"keys", "enter"], + press=["ctrl+p"], ) +def test_help_panel(snap_compare): + """Test help panel.""" + + class HelpPanelApp(App): + def compose(self) -> ComposeResult: + yield Input() + + async def run_before(pilot: Pilot): + pilot.app.query(Input).first().cursor_blink = False + await pilot.press(App.COMMAND_PALETTE_BINDING) + await pilot.pause() + await pilot.press(*"keys") + await pilot.press("enter") + await pilot.app.workers.wait_for_complete() + + assert snap_compare(HelpPanelApp(), terminal_size=(100, 30), run_before=run_before) + + def test_scroll_page_down(snap_compare): """Regression test for https://github.com/Textualize/textual/issues/4914""" # Should show 25 at the top