1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
|
"""
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):
Cache.remove(self._CACHE_CATEGORY, None) # clears the whole cache
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.sorted(ns.bsn.Entity, 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 ##
|