Repository: Textualize/textual

Issue:
Command palette flicker when there are no results
When there are no results, each keypress produces a flicker where the "No matches found" message is shown and hidden. I think we should be able to avoid this, so the "No matches found" message persists.

https://github.com/Textualize/textual/assets/554369/98e93373-e7e1-4f95-84e7-0f193684cc7c




Discussion:
Thanks; I'll double check this. I *think* it used to work like this and then I made a point of ensuring the message got cleared up each time until it was established because, with a slow command source, as you backspaced it could end up looking like no matches were found when that hadn't actually been established yet. Consider typing:

`this is a really long command for which there is no match`

and then hitting backspace once, and unknown to the user, there *will* be a match but it'll take seconds to load it up: it'll say there isn't a match and so they might start backspacing up more and causing the search to start again when it wasn't necessary.
Suggestion from @willmcgugan over the desks: keep it sort of as is, but rather than show it the moment we know there are no matches, set a timer to show it, and kill that timer when more typing happens (akin to how we made the loading indicator works).

I like this approach.

Resolving patch:
diff --git a/src/textual/command.py b/src/textual/command.py
--- a/src/textual/command.py
+++ b/src/textual/command.py
@@ -458,6 +458,8 @@ def __init__(self) -> 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."""
+        self._no_matches_timer: Timer | None = None
+        """Keeps track of if there are 'No matches found' message waiting to be displayed."""
         self._providers: list[Provider] = []
         """List of Provider instances involved in searches."""
 
@@ -559,6 +561,37 @@ def _become_busy() -> None:
 
         self._busy_timer = self.set_timer(self._BUSY_COUNTDOWN, _become_busy)
 
+    def _stop_no_matches_countdown(self) -> None:
+        """Stop any 'No matches' countdown that's in effect."""
+        if self._no_matches_timer is not None:
+            self._no_matches_timer.stop()
+            self._no_matches_timer = None
+
+    _NO_MATCHES_COUNTDOWN: Final[float] = 0.5
+    """How many seconds to wait before showing 'No matches found'."""
+
+    def _start_no_matches_countdown(self) -> None:
+        """Start a countdown to showing that there are no matches for the query.
+
+        Adds a 'No matches found' option to the command list after `_NO_MATCHES_COUNTDOWN` seconds.
+        """
+        self._stop_no_matches_countdown()
+
+        def _show_no_matches() -> None:
+            command_list = self.query_one(CommandList)
+            command_list.add_option(
+                Option(
+                    Align.center(Text("No matches found")),
+                    disabled=True,
+                    id=self._NO_MATCHES,
+                )
+            )
+
+        self._no_matches_timer = self.set_timer(
+            self._NO_MATCHES_COUNTDOWN,
+            _show_no_matches,
+        )
+
     def _watch__list_visible(self) -> None:
         """React to the list visible flag being toggled."""
         self.query_one(CommandList).set_class(self._list_visible, "--visible")
@@ -861,13 +894,7 @@ async def _gather_commands(self, search_value: str) -> None:
         # 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,
-                )
-            )
+            self._start_no_matches_countdown()
 
     @on(Input.Changed)
     def _input(self, event: Input.Changed) -> None:
@@ -878,6 +905,8 @@ def _input(self, event: Input.Changed) -> None:
         """
         event.stop()
         self.workers.cancel_all()
+        self._stop_no_matches_countdown()
+
         search_value = event.value.strip()
         if search_value:
             self._gather_commands(search_value)


Test patch:
diff --git a/tests/command_palette/test_no_results.py b/tests/command_palette/test_no_results.py
--- a/tests/command_palette/test_no_results.py
+++ b/tests/command_palette/test_no_results.py
@@ -18,7 +18,7 @@ async def test_no_results() -> None:
         assert results.visible is False
         assert results.option_count == 0
         await pilot.press("a")
-        await pilot.pause()
+        await pilot.pause(delay=CommandPalette._NO_MATCHES_COUNTDOWN)
         assert results.visible is True
         assert results.option_count == 1
         assert "No matches found" in str(results.get_option_at_index(0).prompt)