Repository: Textualize/textual

Issue:
CommandPalette events
It could be helpful for apps to know when the command palette is opened, closed, and when a new option in the palette is highlighted.


Discussion:

Resolving patch:
diff --git a/src/textual/command.py b/src/textual/command.py
--- a/src/textual/command.py
+++ b/src/textual/command.py
@@ -34,6 +34,7 @@
 from .containers import Horizontal, Vertical
 from .events import Click, Mount
 from .fuzzy import Matcher
+from .message import Message
 from .reactive import var
 from .screen import Screen, _SystemModalScreen
 from .timer import Timer
@@ -541,6 +542,24 @@ class CommandPalette(_SystemModalScreen[CallbackType]):
     _PALETTE_ID: Final[str] = "--command-palette"
     """The internal ID for the command palette."""
 
+    @dataclass
+    class OptionHighlighted(Message):
+        """Posted to App when an option is highlighted in the command palette."""
+
+        highlighted_event: OptionList.OptionHighlighted
+        """The option highlighted event from the OptionList within the command palette."""
+
+    @dataclass
+    class Opened(Message):
+        """Posted to App when the command palette is opened."""
+
+    @dataclass
+    class Closed(Message):
+        """Posted to App when the command palette is closed."""
+
+        option_selected: bool
+        """True if an option was selected, False if the palette was closed without selecting an option."""
+
     def __init__(self) -> None:
         """Initialise the command palette."""
         super().__init__(id=self._PALETTE_ID)
@@ -623,10 +642,13 @@ def _on_click(self, event: Click) -> None:
         """
         if self.get_widget_at(event.screen_x, event.screen_y)[0] is self:
             self._cancel_gather_commands()
+            self.app.post_message(CommandPalette.Closed(option_selected=False))
             self.dismiss()
 
     def _on_mount(self, _: Mount) -> None:
         """Configure the command palette once the DOM is ready."""
+
+        self.app.post_message(CommandPalette.Opened())
         self._calling_screen = self.app.screen_stack[-2]
 
         match_style = self.get_component_rich_style(
@@ -1085,12 +1107,14 @@ def _select_or_command(
                 # ...we should return it to the parent screen and let it
                 # decide what to do with it (hopefully it'll run it).
                 self._cancel_gather_commands()
+                self.app.post_message(CommandPalette.Closed(option_selected=True))
                 self.dismiss(self._selected_command.command)
 
     @on(OptionList.OptionHighlighted)
     def _stop_event_leak(self, event: OptionList.OptionHighlighted) -> None:
         """Stop any unused events so they don't leak to the application."""
         event.stop()
+        self.app.post_message(CommandPalette.OptionHighlighted(highlighted_event=event))
 
     def _action_escape(self) -> None:
         """Handle a request to escape out of the command palette."""
@@ -1098,6 +1122,7 @@ def _action_escape(self) -> None:
             self._list_visible = False
         else:
             self._cancel_gather_commands()
+            self.app.post_message(CommandPalette.Closed(option_selected=False))
             self.dismiss()
 
     def _action_command_list(self, action: str) -> None:


Test patch:
diff --git a/tests/command_palette/test_events.py b/tests/command_palette/test_events.py
new file mode 100644
--- /dev/null
+++ b/tests/command_palette/test_events.py
@@ -0,0 +1,75 @@
+from typing import Union
+from unittest import mock
+
+from textual import on
+from textual.app import App
+from textual.command import CommandPalette, Hit, Hits, Provider
+
+CommandPaletteEvent = Union[
+    CommandPalette.Opened, CommandPalette.Closed, CommandPalette.OptionHighlighted
+]
+
+
+class SimpleSource(Provider):
+    async def search(self, query: str) -> Hits:
+        def goes_nowhere_does_nothing() -> None:
+            pass
+
+        yield Hit(1, query, goes_nowhere_does_nothing, query)
+
+
+class AppWithActiveCommandPalette(App[None]):
+    COMMANDS = {SimpleSource}
+
+    def __init__(self) -> None:
+        super().__init__()
+        self.events: list[CommandPaletteEvent] = []
+
+    def on_mount(self) -> None:
+        self.action_command_palette()
+
+    @on(CommandPalette.Opened)
+    @on(CommandPalette.Closed)
+    @on(CommandPalette.OptionHighlighted)
+    def record_event(
+        self,
+        event: CommandPaletteEvent,
+    ) -> None:
+        self.events.append(event)
+
+
+async def test_command_palette_opened_event():
+    app = AppWithActiveCommandPalette()
+    async with app.run_test():
+        assert app.events == [CommandPalette.Opened()]
+
+
+async def test_command_palette_closed_event():
+    app = AppWithActiveCommandPalette()
+    async with app.run_test() as pilot:
+        await pilot.press("escape")
+        assert app.events == [CommandPalette.Opened(), CommandPalette.Closed(False)]
+
+
+async def test_command_palette_closed_event_value():
+    app = AppWithActiveCommandPalette()
+    async with app.run_test() as pilot:
+        await pilot.press("a")
+        await pilot.press("down")
+        await pilot.press("enter")
+        assert app.events == [
+            CommandPalette.Opened(),
+            CommandPalette.OptionHighlighted(mock.ANY),
+            CommandPalette.Closed(True),
+        ]
+
+
+async def test_command_palette_option_highlighted_event():
+    app = AppWithActiveCommandPalette()
+    async with app.run_test() as pilot:
+        await pilot.press("a")
+        await pilot.press("down")
+        assert app.events == [
+            CommandPalette.Opened(),
+            CommandPalette.OptionHighlighted(mock.ANY),
+        ]