From 6995834c3cccc177a19d20cf0efb6d8751a3a681 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 22 Aug 2024 11:46:56 +0100 Subject: [PATCH 01/13] added system commands --- src/textual/app.py | 65 +++++++++++++++++-- src/textual/system_commands.py | 44 ++----------- tests/command_palette/test_declare_sources.py | 8 +-- 3 files changed, 71 insertions(+), 46 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 1f1a32b824..dd7b38fb25 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -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" @@ -171,16 +171,19 @@ ) """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]: + +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 +362,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 +925,58 @@ def active_bindings(self) -> dict[str, ActiveBinding]: """ return self.screen.active_bindings + def get_system_commands( + self, + ) -> Iterable[tuple[str, str, CommandCallback]]: + """A generator of system commands used in the command palette. + + 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 [`SystemCommandProvider`][textual.system_commands.SystemCommandProvider] is in `App.COMMANDS` class variable. + + Yields: + tuples of (TITLE, HELP TEXT, CALLBACK) + """ + 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 self.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. diff --git a/src/textual/system_commands.py b/src/textual/system_commands.py index d2ad79c5b6..fa1e8d7884 100644 --- a/src/textual/system_commands.py +++ b/src/textual/system_commands.py @@ -19,46 +19,16 @@ from .types import IgnoreReturnCallbackType -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) -> Iterable[tuple[str, str, IgnoreReturnCallbackType]]: """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() async def discover(self) -> Hits: """Handle a request for the discovery commands for this provider. @@ -67,10 +37,10 @@ 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: + for name, help_text, callback in commands: yield DiscoveryHit( name, - runnable, + callback, help=help_text, ) @@ -89,11 +59,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, } From 0efdb10df88029e11c943b831da4792c9f5aaf57 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 22 Aug 2024 11:50:17 +0100 Subject: [PATCH 02/13] changelog --- CHANGELOG.md | 2 ++ tests/snapshot_tests/test_snapshots.py | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebdd65ff34..011d1ef659 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 ### Fixed diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 6d48d756b1..3baa2eb2d1 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -1450,6 +1450,15 @@ def test_split(snap_compare): assert snap_compare(SNAPSHOT_APPS_DIR / "split.py", terminal_size=(100, 30)) +def test_system_commands(snap_compare): + """Test help panel.""" + assert snap_compare( + SNAPSHOT_APPS_DIR / "help_panel.py", + terminal_size=(100, 30), + press=["ctrl+p"], + ) + + def test_help_panel(snap_compare): """Test help panel.""" assert snap_compare( From b1849e36ef94744a313b0e3e79aaa48ae2f00b72 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 22 Aug 2024 12:08:48 +0100 Subject: [PATCH 03/13] snapshot --- .../guide/command_palette/command01.py | 68 +------ .../guide/command_palette/command02.py | 68 +++++++ docs/guide/command_palette.md | 34 +++- src/textual/app.py | 5 +- .../test_snapshots/test_system_commands.svg | 185 ++++++++++++++++++ tests/snapshot_tests/test_snapshots.py | 10 +- 6 files changed, 296 insertions(+), 74 deletions(-) create mode 100644 docs/examples/guide/command_palette/command02.py create mode 100644 tests/snapshot_tests/__snapshots__/test_snapshots/test_system_commands.svg diff --git a/docs/examples/guide/command_palette/command01.py b/docs/examples/guide/command_palette/command01.py index 2e0a9f82d6..dfe3b2c62c 100644 --- a/docs/examples/guide/command_palette/command01.py +++ b/docs/examples/guide/command_palette/command01.py @@ -1,68 +1,14 @@ -from __future__ import annotations +from textual.app import App, SystemCommandsResult -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) -> SystemCommandsResult: + yield from super().get_system_commands() # (1)! + yield ("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..83b5580e80 100644 --- a/docs/guide/command_palette.md +++ b/docs/guide/command_palette.md @@ -30,19 +30,33 @@ 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 `yield` 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 @@ -57,8 +71,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/src/textual/app.py b/src/textual/app.py index dd7b38fb25..c61da4da48 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -174,6 +174,9 @@ CommandCallback: TypeAlias = "Callable[[], Awaitable[Any]] | Callable[[], Any]" """Signature for callbacks used in [`get_system_commands`][textual.app.App.get_system_commands]""" +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. @@ -927,7 +930,7 @@ def active_bindings(self) -> dict[str, ActiveBinding]: def get_system_commands( self, - ) -> Iterable[tuple[str, str, CommandCallback]]: + ) -> SystemCommandsResult: """A generator of system commands used in the command palette. Implement this method in your App subclass if you want to add custom commands. 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/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 3baa2eb2d1..17df6cb12f 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 @@ -1452,8 +1452,13 @@ def test_split(snap_compare): def test_system_commands(snap_compare): """Test help panel.""" + + 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"], ) @@ -1461,6 +1466,7 @@ def test_system_commands(snap_compare): def test_help_panel(snap_compare): """Test help panel.""" + assert snap_compare( SNAPSHOT_APPS_DIR / "help_panel.py", terminal_size=(100, 30), From 9f66043291b4ee36ca2b4c420046dd587c3257ce Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 22 Aug 2024 12:21:23 +0100 Subject: [PATCH 04/13] added help --- docs/guide/command_palette.md | 2 ++ src/textual/app.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/guide/command_palette.md b/docs/guide/command_palette.md index 83b5580e80..d7bc88cfff 100644 --- a/docs/guide/command_palette.md +++ b/docs/guide/command_palette.md @@ -47,6 +47,7 @@ Here's how we would add a command to ring the terminal bell (a super useful piec ```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. @@ -58,6 +59,7 @@ Here's how we would add a command to ring the terminal bell (a super useful piec This is a straightforward way of adding commands to your app. For more advanced integrations you can implement your own *command providers*. + ## Command providers To add your own command(s) to the command palette, define a [`command.Provider`][textual.command.Provider] class then add it to the [`COMMANDS`][textual.app.App.COMMANDS] class var on your `App` class. diff --git a/src/textual/app.py b/src/textual/app.py index c61da4da48..384f07785f 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -943,7 +943,7 @@ def get_system_commands(self): ``` !!! note - Requires that [`SystemCommandProvider`][textual.system_commands.SystemCommandProvider] is in `App.COMMANDS` class variable. + Requires that [`SystemCommandsProvider`][textual.system_commands.SystemCommandsProvider] is in `App.COMMANDS` class variable. Yields: tuples of (TITLE, HELP TEXT, CALLBACK) From 7a9ab938265589bdfb15efe57d51817c8161da57 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 22 Aug 2024 12:37:30 +0100 Subject: [PATCH 05/13] docs --- docs/examples/guide/command_palette/command01.py | 5 +++-- docs/guide/command_palette.md | 5 +++-- docs/widgets/footer.md | 2 -- src/textual/app.py | 9 +++++---- src/textual/system_commands.py | 2 +- 5 files changed, 12 insertions(+), 11 deletions(-) diff --git a/docs/examples/guide/command_palette/command01.py b/docs/examples/guide/command_palette/command01.py index dfe3b2c62c..fe7b64d836 100644 --- a/docs/examples/guide/command_palette/command01.py +++ b/docs/examples/guide/command_palette/command01.py @@ -1,11 +1,12 @@ from textual.app import App, SystemCommandsResult +from textual.screen import Screen class BellCommandApp(App): """An app with a 'bell' command.""" - def get_system_commands(self) -> SystemCommandsResult: - yield from super().get_system_commands() # (1)! + def get_system_commands(self, screen: Screen) -> SystemCommandsResult: + yield from super().get_system_commands(screen) # (1)! yield ("Bell", "Ring the bell", self.bell) # (2)! diff --git a/docs/guide/command_palette.md b/docs/guide/command_palette.md index d7bc88cfff..b327ddeeec 100644 --- a/docs/guide/command_palette.md +++ b/docs/guide/command_palette.md @@ -36,7 +36,8 @@ 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. -To declare a command `yield` a tuple of `(TITLE, HELP TEXT, CALLABLE)`. +To declare a command, define a `get_system_commands` method, which will receive 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`. @@ -47,7 +48,7 @@ Here's how we would add a command to ring the terminal bell (a super useful piec ```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. diff --git a/docs/widgets/footer.md b/docs/widgets/footer.md index 3d9b0b47de..1227791b8d 100644 --- a/docs/widgets/footer.md +++ b/docs/widgets/footer.md @@ -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). | diff --git a/src/textual/app.py b/src/textual/app.py index 384f07785f..531a7f82b0 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -928,11 +928,12 @@ def active_bindings(self) -> dict[str, ActiveBinding]: """ return self.screen.active_bindings - def get_system_commands( - self, - ) -> SystemCommandsResult: + 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: @@ -967,7 +968,7 @@ def get_system_commands(self): self.action_quit, ) - if self.screen.query("HelpPanel"): + if screen.query("HelpPanel"): yield ( "Hide keys and help panel", "Hide the keys and widget help panel", diff --git a/src/textual/system_commands.py b/src/textual/system_commands.py index fa1e8d7884..b01d97cf2c 100644 --- a/src/textual/system_commands.py +++ b/src/textual/system_commands.py @@ -28,7 +28,7 @@ class SystemCommandsProvider(Provider): @property def _system_commands(self) -> Iterable[tuple[str, str, IgnoreReturnCallbackType]]: """The system commands to reveal to the command palette.""" - yield from self.app.get_system_commands() + yield from self.app.get_system_commands(self.screen) async def discover(self) -> Hits: """Handle a request for the discovery commands for this provider. From bc54961e15e5331cd202aa661389d1e5c52cde2c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 22 Aug 2024 13:16:52 +0100 Subject: [PATCH 06/13] wording --- docs/guide/command_palette.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/guide/command_palette.md b/docs/guide/command_palette.md index b327ddeeec..f0f014dc7d 100644 --- a/docs/guide/command_palette.md +++ b/docs/guide/command_palette.md @@ -36,7 +36,8 @@ 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. -To declare a command, define a `get_system_commands` method, which will receive the screen that was active when the user summoned the command palette. +To declare a command, define a `get_system_commands` method on your App. +Textual will call this 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`. From 1d1e538e104b561eed8589ff82d867ad2aef5445 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 22 Aug 2024 13:17:13 +0100 Subject: [PATCH 07/13] words --- docs/guide/command_palette.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/command_palette.md b/docs/guide/command_palette.md index f0f014dc7d..c51df5c6b6 100644 --- a/docs/guide/command_palette.md +++ b/docs/guide/command_palette.md @@ -37,7 +37,7 @@ These are declared in the [`App.get_system_commands`][textual.app.App.get_system You can implement this method in your App class to add more commands. To declare a command, define a `get_system_commands` method on your App. -Textual will call this with the screen that was active when the user summoned the command palette. +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`. From 9844d898da50946f82fcd5aba7c3613bd17a9897 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 22 Aug 2024 13:33:55 +0100 Subject: [PATCH 08/13] Update tests/snapshot_tests/test_snapshots.py Co-authored-by: Darren Burns --- tests/snapshot_tests/test_snapshots.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 17df6cb12f..87e3811bcd 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -1451,7 +1451,7 @@ def test_split(snap_compare): def test_system_commands(snap_compare): - """Test help panel.""" + """The system commands should appear in the command palette.""" class SimpleApp(App): def compose(self) -> ComposeResult: From 7f724f7668e24fad5830733ee54303cc84c350ec Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 22 Aug 2024 13:51:31 +0100 Subject: [PATCH 09/13] harden test --- .../snapshot_tests/snapshot_apps/help_panel.py | 12 ------------ tests/snapshot_tests/test_snapshots.py | 17 ++++++++++++----- 2 files changed, 12 insertions(+), 17 deletions(-) delete mode 100644 tests/snapshot_tests/snapshot_apps/help_panel.py 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 87e3811bcd..8f421f7aa6 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -1467,11 +1467,18 @@ def compose(self) -> ComposeResult: def test_help_panel(snap_compare): """Test help panel.""" - assert snap_compare( - SNAPSHOT_APPS_DIR / "help_panel.py", - terminal_size=(100, 30), - press=["ctrl+p", *"keys", "enter"], - ) + class HelpPanelApp(App): + def compose(self) -> ComposeResult: + yield Input() + + async def run_before(pilot: Pilot): + 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): From 4f3c7666e57a89330318ebec3ec5ca9b08f769c3 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 22 Aug 2024 14:04:54 +0100 Subject: [PATCH 10/13] SystemCommand --- .../guide/command_palette/command01.py | 8 +++-- docs/guide/command_palette.md | 5 ++-- src/textual/app.py | 29 ++++++++++++------- src/textual/system_commands.py | 23 ++++++++------- 4 files changed, 38 insertions(+), 27 deletions(-) diff --git a/docs/examples/guide/command_palette/command01.py b/docs/examples/guide/command_palette/command01.py index fe7b64d836..54cbb2cc0b 100644 --- a/docs/examples/guide/command_palette/command01.py +++ b/docs/examples/guide/command_palette/command01.py @@ -1,13 +1,15 @@ -from textual.app import App, SystemCommandsResult +from typing import Iterable + +from textual.app import App, SystemCommand from textual.screen import Screen class BellCommandApp(App): """An app with a 'bell' command.""" - def get_system_commands(self, screen: Screen) -> SystemCommandsResult: + def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]: yield from super().get_system_commands(screen) # (1)! - yield ("Bell", "Ring the bell", self.bell) # (2)! + yield SystemCommand("Bell", "Ring the bell", self.bell) # (2)! if __name__ == "__main__": diff --git a/docs/guide/command_palette.md b/docs/guide/command_palette.md index c51df5c6b6..812c423ea4 100644 --- a/docs/guide/command_palette.md +++ b/docs/guide/command_palette.md @@ -38,9 +38,8 @@ You can implement this method in your App class to add more 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`. +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 the function to run if the user selects the command. + Here's how we would add a command to ring the terminal bell (a super useful piece of functionality): diff --git a/src/textual/app.py b/src/textual/app.py index 531a7f82b0..d5751482df 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -38,6 +38,7 @@ Generic, Iterable, Iterator, + NamedTuple, Sequence, Type, TypeVar, @@ -174,8 +175,14 @@ CommandCallback: TypeAlias = "Callable[[], Awaitable[Any]] | Callable[[], Any]" """Signature for callbacks used in [`get_system_commands`][textual.app.App.get_system_commands]""" -SystemCommandsResult: TypeAlias = "Iterable[tuple[str, str, CommandCallback]]" -"""The return type of App.get_system_commands""" + +class SystemCommand(NamedTuple): + """Defines a system command (yielded from [`get_system_commands`][textual.app.App.get_system_commands]).""" + + title: str + help: str + callback: CommandCallback + discover: bool = True def get_system_commands_provider() -> type[SystemCommandsProvider]: @@ -928,7 +935,7 @@ def active_bindings(self) -> dict[str, ActiveBinding]: """ return self.screen.active_bindings - def get_system_commands(self, screen: Screen) -> SystemCommandsResult: + def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]: """A generator of system commands used in the command palette. Args: @@ -938,44 +945,44 @@ def get_system_commands(self, screen: Screen) -> SystemCommandsResult: Here is an example: ```python - def get_system_commands(self): + def get_system_commands(self) -> Iterable[SystemCommand]: yield from super().get_system_commands() - yield ("Bell", "Ring the bell", self.bell) + yield SystemCommand("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) + [SystemCommand][textual.app.SystemCommand] instances. """ if self.dark: - yield ( + yield SystemCommand( "Light mode", "Switch to a light background", self.action_toggle_dark, ) else: - yield ( + yield SystemCommand( "Dark mode", "Switch to a dark background", self.action_toggle_dark, ) - yield ( + yield SystemCommand( "Quit the application", "Quit the application as soon as possible", self.action_quit, ) if screen.query("HelpPanel"): - yield ( + yield SystemCommand( "Hide keys and help panel", "Hide the keys and widget help panel", self.action_hide_help_panel, ) else: - yield ( + yield SystemCommand( "Show keys and help panel", "Show help for the focused widget and a summary of available keys", self.action_show_help_panel, diff --git a/src/textual/system_commands.py b/src/textual/system_commands.py index b01d97cf2c..9969fb0d9b 100644 --- a/src/textual/system_commands.py +++ b/src/textual/system_commands.py @@ -13,10 +13,12 @@ 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 SystemCommandsProvider(Provider): @@ -26,7 +28,7 @@ class SystemCommandsProvider(Provider): """ @property - def _system_commands(self) -> Iterable[tuple[str, str, IgnoreReturnCallbackType]]: + def _system_commands(self) -> SystemCommandsResult: """The system commands to reveal to the command palette.""" yield from self.app.get_system_commands(self.screen) @@ -37,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, help_text, callback in commands: - yield DiscoveryHit( - name, - callback, - 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. @@ -59,7 +62,7 @@ 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, help_text, callback in self._system_commands: + for name, help_text, callback, *_ in self._system_commands: if (match := matcher.match(name)) > 0: yield Hit( match, From b7c6e659a5b405280c4de95015162f54517ed48e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 22 Aug 2024 14:06:00 +0100 Subject: [PATCH 11/13] docstrings --- src/textual/app.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/textual/app.py b/src/textual/app.py index d5751482df..9ddb16637e 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -180,9 +180,13 @@ class SystemCommand(NamedTuple): """Defines a system command (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]: From da238d11563ab03725c646c64cf00d6500edeade Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 22 Aug 2024 14:12:04 +0100 Subject: [PATCH 12/13] docs --- docs/guide/command_palette.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/guide/command_palette.md b/docs/guide/command_palette.md index 812c423ea4..85b30c483b 100644 --- a/docs/guide/command_palette.md +++ b/docs/guide/command_palette.md @@ -38,8 +38,9 @@ You can implement this method in your App class to add more 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 [`SystemCommand`][textual.app.SystemCommand] object which contains `title` and `help` text to be shown in the command palette, and `callback` which is the function to run if the user selects the command. +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. Here's how we would add a command to ring the terminal bell (a super useful piece of functionality): From 00fbf2d5faa641c6890e272f7d0bbd4e47967294 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 22 Aug 2024 14:23:21 +0100 Subject: [PATCH 13/13] no blink --- src/textual/app.py | 2 +- tests/snapshot_tests/test_snapshots.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/textual/app.py b/src/textual/app.py index 9ddb16637e..775280b152 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -177,7 +177,7 @@ class SystemCommand(NamedTuple): - """Defines a system command (yielded from [`get_system_commands`][textual.app.App.get_system_commands]).""" + """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).""" diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 8f421f7aa6..89e893c0a1 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -1472,6 +1472,7 @@ 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")