[pitivi] medialibrary: Allow searching by tags



commit 836c311c740f9856b7212fce9e3b06a7f6c7ed69
Author: AsociTon <asociton outlook com>
Date:   Wed Aug 19 21:16:50 2020 +0530

    medialibrary: Allow searching by tags
    
    Developed in collaboration with aleb

 data/ui/medialibrary.ui    |  4 +-
 pitivi/medialibrary.py     | 91 +++++++++++++++++++++++++++++++++++++++++-----
 tests/test_medialibrary.py | 37 +++++++++++++++++--
 3 files changed, 116 insertions(+), 16 deletions(-)
---
diff --git a/data/ui/medialibrary.ui b/data/ui/medialibrary.ui
index b4b901aec..896a59866 100644
--- a/data/ui/medialibrary.ui
+++ b/data/ui/medialibrary.ui
@@ -153,7 +153,7 @@
         <property name="can_focus">False</property>
         <property name="margin_start">5</property>
         <child>
-          <object class="GtkEntry" id="media_search_entry">
+          <object class="GtkSearchEntry" id="media_search_entry">
             <property name="visible">True</property>
             <property name="can_focus">True</property>
             <property name="margin_start">3</property>
@@ -163,7 +163,7 @@
             <property name="primary_icon_tooltip_text" translatable="yes" comments="This is used as a 
toolbar button tooltip. Here, &quot;select&quot; means &quot;find&quot; rather than &quot;choose&quot;. It is 
not the user who selects, but rather the user requesting the application to select the relevant 
items.">Select clips that have not been used in the project</property>
             <property name="secondary_icon_tooltip_text" translatable="yes">Show all clips</property>
             <property name="placeholder_text" translatable="yes">Search...</property>
-            <signal name="changed" handler="_search_entry_changed_cb" swapped="no"/>
+            <signal name="search-changed" handler="_search_entry_search_changed_cb" swapped="no"/>
             <signal name="icon-release" handler="_search_entry_icon_press_cb" swapped="no"/>
             <child internal-child="accessible">
               <object class="AtkObject" id="media_search_entry-atkobject">
diff --git a/pitivi/medialibrary.py b/pitivi/medialibrary.py
index 12b50c7e2..bbd640a4d 100644
--- a/pitivi/medialibrary.py
+++ b/pitivi/medialibrary.py
@@ -18,6 +18,7 @@
 # You should have received a copy of the GNU Lesser General Public
 # License along with this program; if not, see <http://www.gnu.org/licenses/>.
 import os
+import re
 import subprocess
 import sys
 import time
@@ -25,6 +26,7 @@ from enum import IntEnum
 from gettext import gettext as _
 from gettext import ngettext
 from hashlib import md5
+from typing import Set
 
 import cairo
 from gi.repository import Gdk
@@ -518,6 +520,8 @@ class MediaLibraryWidget(Gtk.Box, Loggable):
         self.import_start_time = time.time()
         self._last_imported_uris = set()
         self.__last_proxying_estimate_time = _("Unknown")
+        self._last_prefix: str = ""
+        self._last_suggested_tags: Set[str] = set()
 
         self.set_orientation(Gtk.Orientation.VERTICAL)
         builder = Gtk.Builder()
@@ -548,7 +552,17 @@ class MediaLibraryWidget(Gtk.Box, Loggable):
         toolbar.get_style_context().add_class(Gtk.STYLE_CLASS_INLINE_TOOLBAR)
         self._import_button = builder.get_object("media_import_button")
         self._listview_button = builder.get_object("media_listview_button")
+
         self.search_entry = builder.get_object("media_search_entry")
+        search_completion = Gtk.EntryCompletion()
+        self.search_store = Gtk.ListStore(str)
+        search_completion.set_model(self.search_store)
+        search_completion.set_text_column(0)
+        search_completion.set_minimum_key_length(0)
+        search_completion.set_popup_completion(True)
+        search_completion.set_inline_selection(False)
+        self.search_entry.set_completion(search_completion)
+
         bottom_toolbar_container = builder.get_object("medialibrary_bottom_toolbar_container")
         bottom_toolbar = builder.get_object("medialibrary_bottom_toolbar")
         bg_color = bottom_toolbar_container.get_style_context().get_background_color(Gtk.StateFlags.NORMAL)
@@ -613,8 +627,6 @@ class MediaLibraryWidget(Gtk.Box, Loggable):
                                self.remove_assets_action,
                                _("Remove the selected assets"))
 
-        # self.update_assets_tag.
-
         self.insert_at_end_action = Gio.SimpleAction.new("insert-assets-at-end", None)
         self.insert_at_end_action.connect("activate", self._insert_end_cb)
         actions_group.add_action(self.insert_at_end_action)
@@ -637,6 +649,8 @@ class MediaLibraryWidget(Gtk.Box, Loggable):
         self.pack_start(self._progressbar, False, False, 0)
         self.pack_start(bottom_toolbar_container, False, False, 0)
 
+        self.filter_store()
+
     def create_iconview_widget_func(self, item):
         box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
         box.set_halign(Gtk.Align.CENTER)
@@ -738,7 +752,7 @@ class MediaLibraryWidget(Gtk.Box, Loggable):
     def _insert_end_cb(self, unused_action, unused_parameter):
         self.app.gui.editor.timeline_ui.insert_assets(self.get_selected_assets(), -1)
 
-    def _search_entry_changed_cb(self, unused_entry):
+    def _search_entry_search_changed_cb(self, entry):
         self.filter_store()
 
     def _search_entry_icon_press_cb(self, entry, icon_pos, event):
@@ -750,20 +764,77 @@ class MediaLibraryWidget(Gtk.Box, Loggable):
             self.flowbox.grab_focus()
 
     def filter_store(self):
+        """Updates the search field suggestions and filters the assets."""
         text = self.search_entry.get_text().lower()
+
+        # Process the search text.
+        tags = set()
+        words = []
+        last_token_match = None
+        last_token_tag = None
+        for match in re.finditer(r"\S+", text):
+            token = text[match.start():match.end()]
+            parts = token.split(":", 1)
+            last_token_tag = None
+            if len(parts) == 2 and parts[0] == "tag":
+                tag = parts[1]
+                if tag in self.witnessed_tags:
+                    tags.add(tag)
+                    last_token_tag = tag
+            else:
+                words.append(token)
+            last_token_match = match
+
+        # Update the search suggestions.
+        # The prefix to which we append the suggestions is chosen such that
+        # it excludes the last token, unless there is whitespace after it,
+        # in which case it is included.
+        suggested_tags = set(tags)
+        if last_token_match:
+            if last_token_match.end() == len(text):
+                # Exclude the last token from the suggestions prefix.
+                prefix = text[:last_token_match.start()]
+                suggested_tags.discard(last_token_tag)
+            else:
+                # There is some whitespace after the last token, so then
+                # we include it in the suggestions prefix.
+                prefix = text
+        else:
+            # There is no token, only whitespace or nothing.
+            prefix = text
+        self._update_search_suggestions(prefix, suggested_tags)
+
+        # Filter the assets.
+
         # With many hundred clips in an iconview with dynamic columns and
         # ellipsizing, doing needless searches is very expensive.
         # Realistically, nobody expects to search for only one character,
         # and skipping that makes a huge difference in responsiveness.
-        if len(text) == 1:
-            self.flowbox.show_all()
-            return
-
         # We must convert to markup form to be able to search for &, ', etc.
-        text = GLib.markup_escape_text(text)
+        escaped_words = [GLib.markup_escape_text(word) for word in words if len(word) > 1]
+
         for i, row_widget in enumerate(self.flowbox):
-            matches = text in self.store[i].infotext.lower()
-            row_widget.set_visible(matches)
+            matches = not tags.difference(self.store[i].tags)
+            if matches:
+                row_text = self.store[i].infotext.lower()
+                matches = all([escaped_word in row_text for escaped_word in escaped_words])
+            row_widget.set_visible(bool(matches))
+
+    def _update_search_suggestions(self, prefix: str, entered_tags: Set[str]):
+        """Updates the suggestions for the search field."""
+        tags = self.witnessed_tags - entered_tags
+
+        if self._last_prefix == prefix and self._last_suggested_tags == tags:
+            # Nothing changed.
+            return
+
+        self._last_prefix = prefix
+        self._last_suggested_tags = tags
+
+        self.search_store.clear()
+        for tag in sorted(tags):
+            autocomplete_suggestion = "{}tag:{}".format(prefix, tag)
+            self.search_store.append([autocomplete_suggestion])
 
     def _connect_to_project(self, project):
         """Connects signal handlers to the specified project."""
diff --git a/tests/test_medialibrary.py b/tests/test_medialibrary.py
index 8636f74fe..5fa9e1aaa 100644
--- a/tests/test_medialibrary.py
+++ b/tests/test_medialibrary.py
@@ -520,8 +520,11 @@ class TestMediaLibrary(BaseTestMediaLibrary):
 class TestTaggingAssets(BaseTestMediaLibrary):
 
     def import_assets_in_medialibrary(self):
-        samples = ["30fps_numeroted_frames_red.mkv",
-                   "30fps_numeroted_frames_blue.webm", "1sec_simpsons_trailer.mp4"]
+        samples = [
+            "30fps_numeroted_frames_red.mkv",
+            "30fps_numeroted_frames_blue.webm",
+            "1sec_simpsons_trailer.mp4",
+        ]
         with common.cloned_sample(*samples):
             self.check_import(samples, proxying_strategy=ProxyingStrategy.NOTHING)
 
@@ -557,7 +560,6 @@ class TestTaggingAssets(BaseTestMediaLibrary):
                 raise Exception(tag_name)
 
     def test_adding_tags(self):
-        self.mainloop = common.create_main_loop()
         self.import_assets_in_medialibrary()
         self.medialibrary.flowbox.unselect_all()
 
@@ -612,7 +614,6 @@ class TestTaggingAssets(BaseTestMediaLibrary):
         self.medialibrary.tags_popover.hide()
 
     def test_removing_tags(self):
-        self.mainloop = common.create_main_loop()
         self.import_assets_in_medialibrary()
         tag = "TAG"
         self.add_new_tag(tag)
@@ -667,3 +668,31 @@ class TestTaggingAssets(BaseTestMediaLibrary):
         self.assertEqual(self.medialibrary.witnessed_tags, set())
         for item in self.medialibrary.store:
             self.assertEqual(item.tags, set())
+
+    def check_suggestions(self, text, expected_suggestions):
+        self.medialibrary.search_entry.props.text = text
+        self.medialibrary.search_entry.emit("search-changed")
+
+        suggestions = [item[0] for item in self.medialibrary.search_store]
+        self.assertListEqual(suggestions, expected_suggestions)
+
+    def test_search_suggestions(self):
+        self.import_assets_in_medialibrary()
+
+        self.medialibrary.witnessed_tags = {"red", "blue", "green"}
+
+        self.check_suggestions("", ["tag:blue", "tag:green", "tag:red"])
+        self.check_suggestions("tag:", ["tag:blue", "tag:green", "tag:red"])
+        # Keep in mind that only the suggestions matching the text are shown.
+        # For example at this point, only the "tag:blue" suggestion is shown
+        # because it's the only one that matches the "tag:b" text.
+        self.check_suggestions("tag:b", ["tag:blue", "tag:green", "tag:red"])
+
+        self.check_suggestions("tag:blue ", ["tag:blue tag:green", "tag:blue tag:red"])
+        self.check_suggestions("tag:blue tag:", ["tag:blue tag:green", "tag:blue tag:red"])
+        self.check_suggestions("tag:blue tag:red", ["tag:blue tag:green", "tag:blue tag:red"])
+
+        self.check_suggestions("tag:blue tag:red ", ["tag:blue tag:red tag:green"])
+        self.check_suggestions("tag:blue tag:red word1", ["tag:blue tag:red tag:green"])
+
+        self.check_suggestions("tag:blue tag:red word1 ", ["tag:blue tag:red word1 tag:green"])


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]