From f6de8a2f568419fd4ea818f3791242f177a87fba Mon Sep 17 00:00:00 2001 From: Matthias Baumgartner Date: Wed, 25 Jan 2023 11:31:08 +0100 Subject: search actions early port --- .gitignore | 1 + tagit/actions/__init__.py | 8 +- tagit/actions/search.kv | 25 ++ tagit/actions/search.py | 334 +++++++++++++++++++++ tagit/assets/icons/scalable/search/apply.svg | 144 +++++++++ .../icons/scalable/search/exclude_filter.svg | 173 +++++++++++ .../icons/scalable/search/exclusive_filter.svg | 203 +++++++++++++ tagit/assets/icons/scalable/search/sort_key.svg | 158 ++++++++++ .../icons/scalable/search/sort_order_down.svg | 170 +++++++++++ .../assets/icons/scalable/search/sort_order_up.svg | 166 ++++++++++ tagit/widgets/filter.py | 5 +- 11 files changed, 1380 insertions(+), 7 deletions(-) create mode 100644 tagit/actions/search.kv create mode 100644 tagit/actions/search.py create mode 100644 tagit/assets/icons/scalable/search/apply.svg create mode 100644 tagit/assets/icons/scalable/search/exclude_filter.svg create mode 100644 tagit/assets/icons/scalable/search/exclusive_filter.svg create mode 100644 tagit/assets/icons/scalable/search/sort_key.svg create mode 100644 tagit/assets/icons/scalable/search/sort_order_down.svg create mode 100644 tagit/assets/icons/scalable/search/sort_order_up.svg diff --git a/.gitignore b/.gitignore index 767d1af..4b3bd39 100644 --- a/.gitignore +++ b/.gitignore @@ -36,5 +36,6 @@ tagit/assets/icons/kivy/browser* tagit/assets/icons/kivy/filter* tagit/assets/icons/kivy/misc* tagit/assets/icons/kivy/planes* +tagit/assets/icons/kivy/search* ## EOF ## diff --git a/tagit/actions/__init__.py b/tagit/actions/__init__.py index c138655..bf99807 100644 --- a/tagit/actions/__init__.py +++ b/tagit/actions/__init__.py @@ -97,11 +97,11 @@ class ActionBuilder(BuilderBase): 'ShowBrowsing': planes.ShowBrowsing, 'ShowCodash': planes.ShowCodash, ## search - #'Search': search.Search, - #'ShowSelected': search.ShowSelected, - #'RemoveSelected': search.RemoveSelected, + 'Search': search.Search, + 'ShowSelected': search.ShowSelected, + 'RemoveSelected': search.RemoveSelected, #'SortKey': search.SortKey, - #'SortOrder': search.SortOrder, + 'SortOrder': search.SortOrder, ## session #'LoadSession': session.LoadSession, #'CreateSession': session.CreateSession, diff --git a/tagit/actions/search.kv b/tagit/actions/search.kv new file mode 100644 index 0000000..00f6d6d --- /dev/null +++ b/tagit/actions/search.kv @@ -0,0 +1,25 @@ +#:import resource_find kivy.resources.resource_find + +: + source: resource_find('atlas://search/search') + tooltip: 'Apply the current search filter' + +: + source: resource_find('atlas://search/exclusive_filter') + tooltip: 'Show only selected items' + +: + source: resource_find('atlas://search/exclude_filter') + tooltip: 'Exclude selected items' + +: + source: resource_find('atlas://search/sort_key') + tooltip: 'Specify the sort key' + +: + source_up: resource_find('atlas://search/sort_order_up') + source_down: resource_find('atlas://search/sort_order_down') + source: self.source_up + tooltip: 'Sort order' + +## EOF ## diff --git a/tagit/actions/search.py b/tagit/actions/search.py new file mode 100644 index 0000000..323f53e --- /dev/null +++ b/tagit/actions/search.py @@ -0,0 +1,334 @@ +""" + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 +""" +# standard imports +from functools import partial +import os + +# kivy imports +from kivy.cache import Cache +from kivy.lang import Builder +import kivy.properties as kp + +# tagit imports +from tagit import config, dialogues +from tagit.external.kivy_garden.contextmenu import ContextMenu +from tagit.utils import clamp, errors +from tagit.utils import ns +from tagit.utils.bsfs import ast +from tagit.widgets import Binding +from tagit.widgets.filter import FilterAwareMixin +from tagit.widgets.session import StorageAwareMixin, ConfigAwareMixin +#from tagit.ai.features.content import ContentFeature, FeatureBuilder # FIXME: mb/port +#from tagit.parsing.search import sortkeys # FIXME: mb/port + + +# inner-module imports +from .action import Action + +# exports +__all__ = [] + + +## code ## + +# load kv +Builder.load_file(os.path.join(os.path.dirname(__file__), 'search.kv')) + +# classes +class Search(Action, StorageAwareMixin, ConfigAwareMixin): + """Apply the current search filter and update the browser.""" + text = kp.StringProperty('Search') + + # internal category for the cache + _CACHE_CATEGORY = 'tagit.search' + + def ktrigger(self, evt): + return Binding.check(evt, self.cfg('bindings', 'search', 'search')) + + def on_root(self, wx, root): + Action.on_root(self, wx, root) + ConfigAwareMixin.on_root(self, wx, root) + StorageAwareMixin.on_root(self, wx, root) + + def on_config_changed(self, session, key, value): + """Update cache settings.""" + if self._CACHE_CATEGORY not in Cache._categories: + pass + elif key == ('ui', 'standalone', 'search', 'cache_items'): + value = None if value <= 0 else value + Cache._categories[self._CACHE_CATEGORY]['limit'] = value + elif key == ('ui', 'standalone', 'search', 'cache_timeout'): + value = None if value <= 0 else value + Cache._categories[self._CACHE_CATEGORY]['timeout'] = value + + def on_cfg(self, wx, cfg): + """Initialize the cache.""" + if self._CACHE_CATEGORY not in Cache._categories: + n_items = self.cfg('ui', 'standalone', 'search', 'cache_items') + n_items = None if n_items <= 0 else n_items + timeout = self.cfg('ui', 'standalone', 'search', 'cache_timeout') + timeout = None if timeout <= 0 else timeout + Cache.register(self._CACHE_CATEGORY, n_items, timeout) + + def on_storage_modified(self, sender): + # clear the whole cache + Cache.remove(self._CACHE_CATEGORY, None) + + def on_predicate_modified(self, sender, predicate, objects, diff): + self.apply() + return # FIXME: mb/port + tbd = set() + # walk through cache + for ast, sort in Cache._objects[self._CACHE_CATEGORY]: + # check ast + if ast is not None: + for token in ast: + if token.predicate() == predicate: + if predicate in ('tag', 'group') and \ + len(set(diff) & set(token.condition())) == 0: + # tag predicate but the tag in question was not changed; skip + continue + tbd.add((ast, sort)) + break # no need to search further + + # check sort + if sort is not None: + if sort.predicate() == predicate: + tbd.add((ast, sort)) + + for key in tbd: + Cache.remove(self._CACHE_CATEGORY, key) + + # re-apply searches + self.apply() + + def apply(self): + browser = self.root.browser + filter = self.root.filter + session = self.root.session + + with browser: + # get query + query, sort = filter.get_query() + # log search + # FIXME: mb/port/log + #session.log.log_search( + # 'filter', + # session.storage.lib.meta, + # (filter.t_head, filter.t_tail), + # (filter.f_head + [browser.frame], filter.f_tail), + # ) + + # apply search or fetch it from the cache + items = Cache.get(self._CACHE_CATEGORY, (query, sort), None) + if items is None: + # FIXME: mb/port: consider sort + items = list(session.storage.get(ns.bsfs.File, query)) + Cache.append(self._CACHE_CATEGORY, (query, sort), items) + + # apply search order because it's cheaper to do it here rather + # than in the backend (also see uix.kivy.filter.get_query). + items = list(reversed(items[:])) if filter.sortdir else items[:] + # update browser + browser.set_items(items) + + +class ShowSelected(Action): + """Show only selected items.""" + text = kp.StringProperty('Selected only') + + def ktrigger(self, evt): + return Binding.check(evt, self.cfg('bindings', 'search', 'exclusive')) + + def apply(self): + with self.root.browser as browser: + if len(browser.selection) == 0: + # silently ignore if no images selected + pass + elif len(browser.selection) == 1 and list(browser.selection)[0] in browser.folds: + # selection is a group + self.root.trigger('OpenGroup', list(browser.selection)[0]) + else: + token = ast.filter.IsIn(browser.unfold(browser.selection)) + self.root.trigger('AddToken', token) + + +class RemoveSelected(Action): + """Exclude selected items.""" + text = kp.StringProperty('Exclude selection') + + def ktrigger(self, evt): + return Binding.check(evt, self.cfg('bindings', 'search', 'remove')) + + def apply(self): + with self.root.browser as browser: + if len(browser.selection) == 0: + # silently ignore if no images selected + pass + else: + new_cursor = browser.neighboring_unselected() + token = ast.filter.IsNotIn(browser.unfold(browser.selection)) + self.root.trigger('AddToken', token) + # fix frame + browser.cursor = new_cursor + browser.selection = {browser.cursor} if browser.cursor is not None else set() + self.root.trigger('JumpToCursor') + + +class SortKey(Action): + """Select by which property items are ordered.""" + text = kp.StringProperty('Sort by') + + def apply(self, predicate=None): + if predicate is None: + x = self.pos[0] + self.width + y = self.pos[1] + self.height + self.menu.show(x, y) + else: + self.set_sortkey(predicate) + + def on_root(self, wx, root): + super(SortKey, self).on_root(wx, root) + + # Order is essential here: + # 1. the menu has to be created + # 2. the menu has to be attached to a parent + # 3. the menu has to be populated + # The visibility has to be triggered after (2) or (3) + self.menu = ContextMenu( + bounding_box_widget = self.root, + cancel_handler_widget = self.root) + self.root.add_widget(self.menu) + self.menu._on_visible(False) + + # TODO: The whole sortkeys setup is rather brittle + # e.g. what happens if new features become available at runtime? + + # default sortkeys + return # FIXME: mb/port + + options = sortkeys.scope.library | sortkeys.typedef.anchored # FIXME: mb/port + # apply whitelist and blacklist config + options -= set(self.cfg('ui', 'standalone', 'search', 'sort_blacklist')) + whitelist = set(self.cfg('ui', 'standalone', 'search', 'sort_whitelist')) + whitelist = whitelist if len(whitelist) else options + options &= whitelist + # TODO: If there are several versions of the same feature class, keep only the most frequent + # * get feature predicates and their feature class (i.e. name) + # * if needed, get their frequencies via Features.Entities(ctrl.stor.num, fid) + # For now, all known features are used. + + # populate menu + for sortkey in sorted(options): + text = sortkey + if ContentFeature.is_feature_id(sortkey): + text = FeatureBuilder.class_from_guid(sortkey).friendly_guid(sortkey) + + self.menu.add_text_item( + text=text, + on_release=partial(self.release_wrapper, sortkey) + ) + + def release_wrapper(self, sortkey, *args): + # hide + self.menu.hide() + # trigger event + self.set_sortkey(sortkey) + + def set_sortkey(self, predicate): + return # FIXME: mb/port + with self.root.filter as filter: + try: + # TODO: What if a predicate accepts several types (e.g. num and anchored) + if predicate in sortkeys.typedef.anchored: + cursor = self.root.browser.cursor + if cursor is None: + raise errors.UserError('an image needs to be selected for similarity sort.') + # TODO: We normally want the anchored search to be sorted most similar + # to least similar (sortdir=False). We could adjust the sortdir automatically. + # Note that VFilterAction_SortOrder would *not* get notified automatically. + filter.sortkey = partial(ast.AnchoredSort, predicate, cursor.guid) + elif predicate in sortkeys.typedef.numerical: + filter.sortkey = partial(ast.NumericalSort, predicate) + elif predicate in sortkeys.typedef.alphabetical: + filter.sortkey = partial(ast.AlphabeticalSort, predicate) + else: + raise errors.UserError('invalid sort key selected') + + except Exception as e: + dialogues.Error(text=str(e)).open() + + # stick to cursor + self.root.trigger('JumpToCursor') + + +class SortOrder(Action, FilterAwareMixin): + """Switch between ascending and descending order.""" + text = kp.StringProperty('Toggle sort order') + + def on_root(self, wx, root): + Action.on_root(self, wx, root) + FilterAwareMixin.on_root(self, wx, root) + + def on_sortdir(self, wx, sortdir): + if self._image is not None: + self._image.source = self.source_down if sortdir else self.source_up + + def on_filter(self, wx, filter): + # remove old binding + if self.filter is not None: + self.filter.unbind(sortdir=self.on_sortdir) + # add new binding + self.filter = filter + if self.filter is not None: + self.filter.bind(sortdir=self.on_sortdir) + self.on_sortdir(self.filter, self.filter.sortdir) + + def __del__(self): + # remove old binding + if self.filter is not None: + self.filter.unbind(sortdir=self.on_sortdir) + self.filter = None + + def apply(self): + with self.root.filter as filter, \ + self.root.browser as browser: + filter.sortdir = not filter.sortdir + # keep the same field of view as before + browser.offset = clamp(browser.n_items - (browser.offset + browser.page_size), + browser.max_offset) + + +## config ## + +config.declare(('ui', 'standalone', 'search', 'sort_blacklist'), config.List(config.String()), [], + __name__, 'Blacklisted sortkeys', 'Sort keys that will not be shown in the sort selection. This does not affect whitelisted keys.') + +config.declare(('ui', 'standalone', 'search', 'sort_whitelist'), config.List(config.String()), [], + __name__, 'Whitelisted sortkeys', 'Sort keys that will always be shown in the sort selection. Overrules blacklisted keys.') + +config.declare(('ui', 'standalone', 'search', 'cache_items'), config.Unsigned(), 0, + __name__, 'Search cache size', 'Number of searches that are held in cache. Zero means no limit.') + +config.declare(('ui', 'standalone', 'search', 'cache_timeout'), config.Unsigned(), 0, + __name__, 'Search cache timeout', 'Number of seconds until searches are discarded from the search cache. Zero means no limit.') + +# keybindings + +config.declare(('bindings', 'search', 'search'), + config.Keybind(), Binding.simple(Binding.F5), + __name__, Search.text.defaultvalue, Search.__doc__) + +config.declare(('bindings', 'search', 'exclusive'), + config.Keybind(), Binding.simple(Binding.ENTER, Binding.mCTRL, Binding.mREST), + __name__, ShowSelected.text.defaultvalue, ShowSelected.__doc__) + +config.declare(('bindings', 'search', 'remove'), + config.Keybind(), Binding.simple(Binding.DEL, None, Binding.mALL), + __name__, RemoveSelected.text.defaultvalue, RemoveSelected.__doc__) + +## EOF ## diff --git a/tagit/assets/icons/scalable/search/apply.svg b/tagit/assets/icons/scalable/search/apply.svg new file mode 100644 index 0000000..6549ee6 --- /dev/null +++ b/tagit/assets/icons/scalable/search/apply.svg @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/tagit/assets/icons/scalable/search/exclude_filter.svg b/tagit/assets/icons/scalable/search/exclude_filter.svg new file mode 100644 index 0000000..ff6ebcf --- /dev/null +++ b/tagit/assets/icons/scalable/search/exclude_filter.svg @@ -0,0 +1,173 @@ + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/tagit/assets/icons/scalable/search/exclusive_filter.svg b/tagit/assets/icons/scalable/search/exclusive_filter.svg new file mode 100644 index 0000000..d0539f1 --- /dev/null +++ b/tagit/assets/icons/scalable/search/exclusive_filter.svg @@ -0,0 +1,203 @@ + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tagit/assets/icons/scalable/search/sort_key.svg b/tagit/assets/icons/scalable/search/sort_key.svg new file mode 100644 index 0000000..eda2b6b --- /dev/null +++ b/tagit/assets/icons/scalable/search/sort_key.svg @@ -0,0 +1,158 @@ + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/tagit/assets/icons/scalable/search/sort_order_down.svg b/tagit/assets/icons/scalable/search/sort_order_down.svg new file mode 100644 index 0000000..933a668 --- /dev/null +++ b/tagit/assets/icons/scalable/search/sort_order_down.svg @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + diff --git a/tagit/assets/icons/scalable/search/sort_order_up.svg b/tagit/assets/icons/scalable/search/sort_order_up.svg new file mode 100644 index 0000000..96a70f7 --- /dev/null +++ b/tagit/assets/icons/scalable/search/sort_order_up.svg @@ -0,0 +1,166 @@ + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/tagit/widgets/filter.py b/tagit/widgets/filter.py index 332ad34..8a7c1a2 100644 --- a/tagit/widgets/filter.py +++ b/tagit/widgets/filter.py @@ -168,9 +168,8 @@ class Filter(BoxLayout, ConfigAwareMixin): if self.changed: self.redraw() # issue search - # FIXME: mb/port/parsing - #if self.run_search: - # self.root.trigger('Search') + if self.run_search: + self.root.trigger('Search') def redraw(self): self.tokens.clear_widgets() -- cgit v1.2.3