Repository: zulip/zulip-terminal

Issue:
Support multi-word matching when autocompleting topics
When autocompleting other items such as streams, we check the search term against different 'words' in the possible matches. For example, `doc` can match both `documentation` and `api documentation`, `cod` can match `code review` and `live coding`, and `term` can match both `terminal clients` and `zulip-terminal`.

For topics, we do not do so, so that `PR ` only matches topics starting with exactly those characters, not any later 'word'. You should be able to confirm this on chat.zulip.org using zulip terminal.

We should extend the current topic autocompletion logic to behave similarly to that for streams, to handle these kinds of cases - likely with the same ordering as with streams, though that may need confirming since there could be a *lot* of topics compared to streams, and we currently only show up to 10 matches. The web app logic may be useful for comparison.

The UI logic for this is in `boxes.py`, so that's a good place to start. The logic is likely common between the topic searches, but extra test cases to ensure this is working would be important. However, if the matching logic can be completely unified with topics then we may be able to remove some tests later.

Discussion:

Patch:
diff --git a/zulipterminal/helper.py b/zulipterminal/helper.py
index c9817fc..12f402b 100644
--- a/zulipterminal/helper.py
+++ b/zulipterminal/helper.py
@@ -535,9 +535,20 @@ def match_emoji(emoji: str, text: str) -> bool:
 
 
 def match_topics(topic_names: List[str], search_text: str) -> List[str]:
-    return [
-        name for name in topic_names if name.lower().startswith(search_text.lower())
-    ]
+    matching_topics = []
+    delimiters = "-_/"
+    trans = str.maketrans(delimiters, len(delimiters) * " ")
+    for full_topic_name in topic_names:
+        # "abc def-gh" --> ["abc def gh", "def", "gh"]
+        words_to_be_matched = [full_topic_name] + full_topic_name.translate(
+            trans
+        ).split()[1:]
+
+        for word in words_to_be_matched:
+            if word.lower().startswith(search_text.lower()):
+                matching_topics.append(full_topic_name)
+                break
+    return matching_topics
 
 
 DataT = TypeVar("DataT")


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 07e8313..ffafd35 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -587,7 +587,14 @@ def message_history(request: Any) -> List[Dict[str, Any]]:
 
 @pytest.fixture
 def topics() -> List[str]:
-    return ["Topic 1", "This is a topic", "Hello there!"]
+    return [
+        "Topic 1",
+        "This is a topic",
+        "Hello there!",
+        "He-llo there!",
+        "Hello t/here!",
+        "Hello from out-er_space!",
+    ]
 
 
 @pytest.fixture(
diff --git a/tests/ui_tools/test_boxes.py b/tests/ui_tools/test_boxes.py
index d9fb8b1..975b14c 100644
--- a/tests/ui_tools/test_boxes.py
+++ b/tests/ui_tools/test_boxes.py
@@ -96,22 +96,39 @@ class TestWriteBox:
         [
             ("#**Stream 1>T", 0, True, "#**Stream 1>Topic 1**"),
             ("#**Stream 1>T", 1, True, "#**Stream 1>This is a topic**"),
-            ("#**Stream 1>T", 2, True, None),
-            ("#**Stream 1>T", -1, True, "#**Stream 1>This is a topic**"),
-            ("#**Stream 1>T", -2, True, "#**Stream 1>Topic 1**"),
-            ("#**Stream 1>T", -3, True, None),
+            ("#**Stream 1>T", 2, True, "#**Stream 1>Hello there!**"),
+            ("#**Stream 1>T", 3, True, "#**Stream 1>He-llo there!**"),
+            ("#**Stream 1>T", 4, True, "#**Stream 1>Hello t/here!**"),
+            ("#**Stream 1>T", 5, True, None),
+            ("#**Stream 1>T", -1, True, "#**Stream 1>Hello t/here!**"),
+            ("#**Stream 1>T", -2, True, "#**Stream 1>He-llo there!**"),
+            ("#**Stream 1>T", -3, True, "#**Stream 1>Hello there!**"),
+            ("#**Stream 1>T", -4, True, "#**Stream 1>This is a topic**"),
+            ("#**Stream 1>T", -5, True, "#**Stream 1>Topic 1**"),
+            ("#**Stream 1>T", -6, True, None),
             ("#**Stream 1>To", 0, True, "#**Stream 1>Topic 1**"),
             ("#**Stream 1>H", 0, True, "#**Stream 1>Hello there!**"),
             ("#**Stream 1>Hello ", 0, True, "#**Stream 1>Hello there!**"),
             ("#**Stream 1>", 0, True, "#**Stream 1>Topic 1**"),
             ("#**Stream 1>", 1, True, "#**Stream 1>This is a topic**"),
-            ("#**Stream 1>", -1, True, "#**Stream 1>Hello there!**"),
-            ("#**Stream 1>", -2, True, "#**Stream 1>This is a topic**"),
+            ("#**Stream 1>", 2, True, "#**Stream 1>Hello there!**"),
+            ("#**Stream 1>", 3, True, "#**Stream 1>He-llo there!**"),
+            ("#**Stream 1>", 4, True, "#**Stream 1>Hello t/here!**"),
+            ("#**Stream 1>", 5, True, "#**Stream 1>Hello from out-er_space!**"),
+            ("#**Stream 1>", 6, True, None),
+            ("#**Stream 1>", -1, True, "#**Stream 1>Hello from out-er_space!**"),
+            ("#**Stream 1>", -2, True, "#**Stream 1>Hello t/here!**"),
+            ("#**Stream 1>", -3, True, "#**Stream 1>He-llo there!**"),
+            ("#**Stream 1>", -4, True, "#**Stream 1>Hello there!**"),
+            ("#**Stream 1>", -5, True, "#**Stream 1>This is a topic**"),
+            ("#**Stream 1>", -6, True, "#**Stream 1>Topic 1**"),
+            ("#**Stream 1>", -7, True, None),
             # Fenced prefix
             ("#**Stream 1**>T", 0, True, "#**Stream 1>Topic 1**"),
             # Unfenced prefix
             ("#Stream 1>T", 0, True, "#**Stream 1>Topic 1**"),
             ("#Stream 1>T", 1, True, "#**Stream 1>This is a topic**"),
+            ("#Stream 1>T", 2, True, "#**Stream 1>Hello there!**"),
             # Invalid stream
             ("#**invalid stream>", 0, False, None),
             ("#**invalid stream**>", 0, False, None),
@@ -1306,12 +1323,30 @@ class TestWriteBox:
     @pytest.mark.parametrize(
         "text, matching_topics",
         [
-            ("", ["Topic 1", "This is a topic", "Hello there!"]),
-            ("Th", ["This is a topic"]),
+            (
+                "",
+                [
+                    "Topic 1",
+                    "This is a topic",
+                    "Hello there!",
+                    "He-llo there!",
+                    "Hello t/here!",
+                    "Hello from out-er_space!",
+                ],
+            ),
+            ("Th", ["This is a topic", "Hello there!", "He-llo there!"]),
+            ("ll", ["He-llo there!"]),
+            ("her", ["Hello t/here!"]),
+            ("er", ["Hello from out-er_space!"]),
+            ("spa", ["Hello from out-er_space!"]),
         ],
         ids=[
             "no_search_text",
             "single_word_search_text",
+            "split_in_first_word",
+            "split_in_second_word",
+            "first_split_in_third_word",
+            "second_split_in_third_word",
         ],
     )
     def test__topic_box_autocomplete(