""" Part of the tagit module. A copy of the license is provided with the project. Author: Matthias Baumgartner, 2022 """ # standard imports from collections import defaultdict from functools import reduce, partial import io import logging import math import operator import os import typing # kivy imports from kivy.clock import Clock from kivy.core.image.img_pil import ImageLoaderPIL from kivy.lang import Builder from kivy.resources import resource_find from kivy.uix.gridlayout import GridLayout from kivy.uix.image import AsyncImage from kivy.uix.relativelayout import RelativeLayout import kivy.properties as kp # tagit imports from tagit import config from tagit.external.setproperty import SetProperty from tagit.utils import Frame, Resolution, Struct, clamp, ns, ttime, rmatcher from tagit.utils.bsfs import ast # inner-module imports from .loader import Loader from .session import StorageAwareMixin, ConfigAwareMixin # exports __all__: typing.Sequence[str] = ( 'Browser', ) ## code ## logger = logging.getLogger(__name__) # load kv Builder.load_file(os.path.join(os.path.dirname(__file__), 'browser.kv')) # classes class ImageLoaderTagit(ImageLoaderPIL): def load(self, filename): data = super(ImageLoaderTagit, self).load(filename) if len(data) > 1: # source features multiple images res = [(im.width, im.height) for im in data] if len(set(res)) > 1: # images have different resolutions; I'm guessing # it's multiple previews embedded in the same image file. # keep only the largest one. idx = res.index(max(res, key=lambda wh: wh[0]*wh[1])) data = [data[idx]] return data class ItemIndex(list): """A list with constant time in index and contains operations. List items must be hashable. Assumes the list is to be immutable. Trades space for time by constructing an index and set at creation time. """ def __init__(self, items): super(ItemIndex, self).__init__(items) self._item_set = set(items) # FIXME: mb/port: collect into a nodes instance? self._index = {itm: idx for idx, itm in enumerate(items)} def index(self, item): return self._index[item] def __contains__(self, value): return value in self._item_set def as_set(self): return self._item_set class Browser(GridLayout, StorageAwareMixin, ConfigAwareMixin): """The browser displays a grid of item previews.""" # root reference root = kp.ObjectProperty(None) # select modes SELECT_SINGLE = 0 SELECT_MULTI = 1 SELECT_RANGE = 2 SELECT_ADDITIVE = 4 SELECT_SUBTRACTIVE = 8 # selection extras range_base = [] range_origin = None # mode select_mode = kp.NumericProperty(SELECT_SINGLE) # content change_view = kp.BooleanProperty(False) change_grid = kp.BooleanProperty(True) items = kp.ObjectProperty(ItemIndex([])) folds = kp.DictProperty() # frame offset = kp.NumericProperty(0) cursor = kp.ObjectProperty(None, allownone=True) selection = SetProperty() # grid mode GRIDMODE_GRID = 'grid' GRIDMODE_LIST = 'list' gridmode = kp.OptionProperty('grid', options=[GRIDMODE_GRID, GRIDMODE_LIST]) # grid size cols = kp.NumericProperty(3) rows = kp.NumericProperty(3) # page_size is defined in kivy such that it updates automatically # delayed view update event _draw_view_evt = None ## initialization def on_root(self, wx, root): StorageAwareMixin.on_root(self, wx, root) ConfigAwareMixin.on_root(self, wx, root) def on_config_changed(self, session, key, value): with self: if key == ('ui', 'standalone', 'browser', 'cols'): self.cols = max(1, value) elif key == ('ui', 'standalone', 'browser', 'rows'): self.rows = max(1, value) elif key == ('ui', 'standalone', 'browser', 'gridmode'): self.gridmode = value elif key == ('ui', 'standalone', 'browser', 'fold_threshold'): self.redraw() # FIXME: redraw doesn't exist elif key == ('ui', 'standalone', 'browser', 'select_color'): self.change_grid = True def on_cfg(self, wx, cfg): with self: self.cols = max(1, cfg('ui', 'standalone', 'browser', 'cols')) self.rows = max(1, cfg('ui', 'standalone', 'browser', 'rows')) self.gridmode = cfg('ui', 'standalone', 'browser', 'gridmode') def on_storage(self, wx, storage): with self: self.frame = Frame() self.items = ItemIndex([]) ## functions def set_items(self, items): """Set the items. Should be used instead of setting items directly to get the correct folding behaviour. """ items, folds = self.fold(items) self.folds = folds self.items = ItemIndex(items) self.change_view = True def fold(self, items): """Replace items in *items* if they are grouped. Return the new item list and the dict of representatives. """ # get groups and their shadow (group's members in items) groups = defaultdict(set) all_items = reduce(operator.add, items, self.root.session.storage.empty(ns.bsn.Entity)) for obj, grp in all_items.group(node=True, view=list): groups[grp].add(obj) # don't fold groups if few members fold_threshold = self.root.session.cfg('ui', 'standalone', 'browser', 'fold_threshold') groups = {grp: objs for grp, objs in groups.items() if len(objs) > fold_threshold} # don't fold groups that make up all items groups = {grp: objs for grp, objs in groups.items() if len(objs) < len(items)} def superset_exists(grp): """Helper fu to detect subsets.""" for objs in groups.values(): if objs != groups[grp] and groups[grp].issubset(objs): return True return False # create folds folds = { grp.represented_by(): Struct( group=grp, shadow=objs, ) for grp, objs in groups.items() if not superset_exists(grp) } # add representatives for rep in folds: # add representative in place of the first of its members idx = min([items.index(obj) for obj in folds[rep].shadow]) items.insert(idx, rep) # remove folded items for obj in {obj for fold in folds.values() for obj in fold.shadow}: items.remove(obj) return items, folds def unfold(self, items): """Replace group representatives by their group members.""" # fetch each item or their shadow if applicable unfolded = set() for itm in items: if itm in self.folds: unfolded |= self.folds[itm].shadow else: unfolded |= {itm} return reduce(operator.add, unfolded, self.root.session.storage.empty(ns.bsn.Entity)) def neighboring_unselected(self): """Return the item closest to the cursor and not being selected. May return None.""" if self.cursor in self.selection: # set cursor to nearest neighbor cur_idx = self.items.index(self.cursor) sel_idx = {self.items.index(obj) for obj in self.selection} # find available items n_right = {clamp(idx + 1, self.n_items - 1) for idx in sel_idx} n_left = {clamp(idx - 1, self.n_items - 1) for idx in sel_idx} cand = sorted((n_left | n_right) - sel_idx) # find closest to cursor c_dist = [abs(idx - cur_idx) for idx in cand] if len(c_dist) == 0: return None else: # set cursor to item at candidate with minimum distance to cursor return self.items[cand[c_dist.index(min(c_dist))]] else: # cursor isn't selected return self.cursor ## properties @property def frame(self): return Frame(self.cursor, self.selection, self.offset) @frame.setter def frame(self, frame): self.offset = frame.offset self.cursor = frame.cursor self.selection = frame.selection @property def n_items(self): return len(self.items) @property def max_offset(self): return max(0, self.n_items + (self.cols - (self.n_items % self.cols)) % self.cols - self.page_size) ## property listeners def on_cols(self, sender, cols): #self.page_size = self.cols * self.rows self.change_grid = True def on_rows(self, sender, rows): #self.page_size = self.cols * self.rows self.change_grid = True def on_offset(self, sender, offset): self.change_view = True def on_cursor(self, sender, cursor): if cursor is not None: self.root.status.dispatch('on_status', cursor.filename(default='')) def on_items(self, sender, items): self.change_view = True # items might have changed; start caching #if self.root.session.cfg('ui', 'standalone', 'browser', 'cache_all'): # self._preload_all() def on_gridmode(self, sender, mode): self.change_grid = True # resolution might have changed; start caching #if self.root.session.cfg('ui', 'standalone', 'browser', 'cache_all'): # self._preload_all() ## context def __enter__(self): return self def __exit__(self, exc_type, exc_value, traceback): # ensure valid values for cursor, selection, and offset # necessary if old frames were loaded while search filters have changed if self.root.session.cfg('session', 'verbose') > 0: # warn about changes if self.cursor is not None and self.cursor not in self.items: logger.warn(f'Fixing: cursor ({self.cursor})') if not self.selection.issubset(self.items.as_set()): logger.warn('Fixing: selection') if self.offset > self.max_offset or self.offset < 0: logger.warn(f'Fixing: offset ({self.offset} not in [0, {self.max_offset}])') self.cursor = self.cursor if self.cursor in self.items else None self.selection = self.items.as_set() & self.selection self.offset = clamp(self.offset, self.max_offset) # issue redraw if self.change_grid: # grid change requires view change self.draw_grid() self.draw_view() elif self.change_view: timeout = self.root.session.cfg('ui', 'standalone', 'browser', 'page_delay') / 1000 if timeout > 0: self._draw_view_evt = Clock.schedule_once(lambda dt: self.draw_view(), timeout) else: self.draw_view() # reset flags self.change_grid = False self.change_view = False def draw_grid(self): if self.gridmode == self.GRIDMODE_LIST: factory = BrowserDescription elif self.gridmode == self.GRIDMODE_GRID: factory = BrowserImage else: raise UserError(f'gridmode has to be {self.GRIDMODE_GRID} or {self.GRIDMODE_LIST}') self.clear_widgets() for itm in range(self.page_size): wx = factory(browser=self) self.bind(selection=wx.on_selection) self.bind(cursor=wx.on_cursor) self.add_widget(wx) def _cell_resolution(self): return Resolution(self.width/self.cols, self.height/self.rows) def on_change_view(self, wx, change_view): # the view will be updated, hence preloading should be interrupted # if it were active. That's done here since to capture the earliest # time where a view change becomes apparent. if change_view and self._draw_view_evt is not None: self._draw_view_evt.cancel() self._draw_view_evt = None def draw_view(self): self._draw_view_evt = None # revoke images that are still wait to being loaded Loader.clear() #if not self.root.session.cfg('ui', 'standalone', 'browser', 'cache_all'): # Loader.clear() # fetch items items = self.items[self.offset:self.offset+self.page_size] childs = iter(self.children) # reversed since child widgets are in reverse order # preload neighbouring pages n_pages = self.root.session.cfg('ui', 'standalone', 'browser', 'cache_items') n_pages = math.ceil(n_pages / self.page_size) if n_pages > 0: lo = clamp(self.offset - n_pages * self.page_size, self.n_items) cu = clamp(self.offset + self.page_size, self.n_items) hi = clamp(self.offset + (n_pages + 1) * self.page_size, self.n_items) # load previous page # previous before next such that scrolling downwards is prioritised self._preload_items(self.items[lo:self.offset]) # load next page # reversed such that the loader prioritises earlier previews self._preload_items(reversed(self.items[cu:hi])) # clear unused cells for _ in range(self.page_size - len(items)): next(childs).clear() if len(items) == 0: # FIXME: mb/port return resolution = self._cell_resolution() previews = self._fetch_previews(items, resolution) default = resource_find('no_preview.png') for ent, child in zip(reversed(items), childs): if ent in previews: buf = previews[ent] else: buf = open(default, 'rb') child.update(ent, buf, f'{ent}x{resolution}') def _fetch_previews(self, items, resolution): """Fetch previews matching *resolution* for *items*. Return a dict with items as key and a BytesIO as value. Items without valid asset are omitted from the dict. """ # fetch previews node_preview = reduce(operator.add, items).get(ns.bse.preview, node=True) previews = {p for previews in node_preview.values() for p in previews} previews = reduce(operator.add, previews) # FIXME: empty previews # fetch preview resolutions res_preview = previews.get(ns.bsp.width, ns.bsp.height, node=True) # select a preview for each item chosen = {} for ent in items: try: # get previews and their resolution for this ent options = [] for preview in node_preview[ent]: # unpack resolution res = res_preview[preview] width = res.get(ns.bsp.width, 0) height = res.get(ns.bsp.height, 0) options.append((preview, Resolution(width, height))) # select the best fitting preview chosen[ent] = rmatcher.by_area_min(resolution, options) except (KeyError, IndexError): # skip objects w/o preview (KeyError in node_preview) # skip objects w/o valid preview (IndexError in rmatcher) pass # fetch assets assets = reduce(operator.add, chosen.values()).asset(node=True) # FIXME: empty chosen # build ent -> asset mapping and convert raw data to io buffer return { ent: io.BytesIO(assets[thumb]) for ent, thumb in chosen.items() if thumb in assets } #def _preload_all(self): # # prefer loading from start to end # self._preload_items(reversed(self.items)) def _preload_items(self, items, resolution=None): """Load an item into the kivy *Cache* without displaying the image anywhere.""" def _buf_loader(buffer, fname): # helper method to load the image from a raw buffer with buffer as buf: return ImageLoaderTagit(filename=fname, inline=True, rawdata=buf) resolution = resolution if resolution is not None else self._cell_resolution() try: foo = self._fetch_previews(items, resolution) # FIXME: _fetch_previews fails on empty previews/chosen except TypeError: return for obj, buffer in foo.items(): guid = ','.join(obj.guids) source = f'{guid}x{resolution}' Loader.image(source, nocache=False, mipmap=False, anim_delay=0, load_callback=partial(_buf_loader, buffer) # mb: pass load_callback ) class BrowserAwareMixin(object): """Widget that binds to the browser.""" browser = None def on_root(self, wx, root): root.bind(browser=self.on_browser) if root.browser is not None: # initialize with the current browser # Going through the event dispatcher ensures that the object # is initialized properly before on_browser is called. Clock.schedule_once(lambda dt: self.on_browser(root, root.browser)) def on_browser(self, sender, browser): pass class BrowserItem(RelativeLayout): """Just some space for an object.""" browser = kp.ObjectProperty() obj = kp.ObjectProperty(allownone=True) is_cursor = kp.BooleanProperty(False) is_selected = kp.BooleanProperty(False) is_group = kp.BooleanProperty(False) def update(self, obj): self.obj = obj def clear(self): self.obj = None def on_obj(self, wx, obj): self.on_cursor(self.browser, self.browser.cursor) self.on_selection(self.browser, self.browser.selection) self.is_group = obj in self.browser.folds if obj is not None else False def on_cursor(self, browser, cursor): self.is_cursor = (cursor == self.obj) if self.obj is not None else False def on_selection(self, browser, selection): self.is_selected = self.obj in selection if self.obj is not None else False def on_touch_down(self, touch): """Click on item.""" if self.obj is not None and self.collide_point(*touch.pos): if touch.button == 'left': # shift counts as double tap if touch.is_double_tap and not self.browser.root.keys.shift_pressed: # open logger.debug('Item: Double touch in {}'.format(str(self.obj))) if not self.is_selected: self.browser.root.trigger('Select', self.obj) self.browser.root.trigger('OpenExternal') else: # set cursor logger.debug('Item: Touchdown in {}'.format(str(self.obj))) self.browser.root.trigger('SetCursor', self.obj) # must call the parent's method to ensure OpenGroup gets a chance to handle # the mouse event. Also, this must happen *after* processing the event here # so that the cursor is set correctly. return super(BrowserItem, self).on_touch_down(touch) def on_touch_move(self, touch): """Move over item.""" if self.obj is not None and self.collide_point(*touch.pos): if touch.button == 'left': if not self.collide_point(*touch.ppos): self.browser.root.trigger('Select', self.obj) return super(BrowserItem, self).on_touch_move(touch) class BrowserImage(BrowserItem): def update(self, obj, buffer, source): super(BrowserImage, self).update(obj) self.preview.load_image(buffer, source, 1) #self.preview.load_image(buffer, source, obj.orientation(default=1)) # FIXME: mb/port self.preview.set_size(self.size) def clear(self): super(BrowserImage, self).clear() self.preview.clear_image() def on_size(self, wx, size): self.preview.set_size(self.size) class BrowserDescription(BrowserItem): text = kp.StringProperty() def update(self, obj, buffer, source): super(BrowserDescription, self).update(obj) self.preview.load_image(buffer, source, 1) #self.preview.load_image(buffer, source, obj.orientation(default=1)) # FIXME: mb/port self.preview.set_size((self.height, self.height)) def clear(self): super(BrowserDescription, self).clear() self.preview.clear_image() def on_size(self, wx, size): self.preview.set_size((self.height, self.height)) def on_obj(self, wx, obj): super(BrowserDescription, self).on_obj(wx, obj) if self.is_group: # get group and its members grp = self.browser.folds[self.obj].group # FIXME: Here we could actually use a predicate reversal for Nodes.get # members = grp.get(ast.fetch.Node(ast.fetch.Predicate(ns.bse.group, reverse=True))) members = self.browser.root.session.storage.get(ns.bsn.Entity, ast.filter.Any(ns.bse.group, ast.filter.Is(grp))) # get group member's tags member_tags = members.tag.label(node=True) tags_all = set.intersection(*member_tags.values()) tags_any = {tag for tags in member_tags.values() for tag in tags} # get remaining info from representative preds = self.obj.get( ns.bse.mime, ns.bsm.t_created, ) self.text = '{name} [size=20sp]x{count}[/size], {mime}, {time}\n[color=8f8f8f][b]{tags_all}[/b], [size=14sp]{tags_any}[/size][/color]'.format( name=os.path.basename(next(iter(grp.guids))), count=len(members), mime=preds.get(ns.bse.mime, ''), time=ttime.from_timestamp_loc( preds.get(ns.bsm.t_created, ttime.timestamp_min)).strftime('%Y-%m-%d %H:%M'), tags_all=', '.join(sorted(tags_all)), tags_any=', '.join(sorted(tags_any - tags_all)), ) elif self.obj is not None: preds = self.obj.get( ns.bse.filename, ns.bse.filesize, ns.bse.mime, ns.bsm.t_created, (ns.bse.tag, ns.bst.label), ) self.text = '[size=20sp]{filename}[/size], [size=18sp][i]{mime}[/i][/size] -- {time} -- {filesize}\n[color=8f8f8f][size=14sp]{tags}[/size][/color]'.format( filename=preds.get(ns.bse.filename, 'n/a'), mime=preds.get(ns.bse.mime, ''), time=ttime.from_timestamp_loc( preds.get(ns.bsm.t_created, ttime.timestamp_min)).strftime('%Y-%m-%d %H:%M'), filesize=preds.get(ns.bse.filesize, 0), tags=', '.join(sorted(preds.get((ns.bse.tag, ns.bst.label), []))), ) else: self.text = '' class AsyncBufferImage(AsyncImage): """Replacement for kivy.uix.image.AsyncImage that allows to pass a *load_callback* method. The load_callback (fu(filename) -> ImageLoaderTagit) can be used to read a file from something else than a path. However, note that if caching is desired, a filename (i.e. source) should still be given. """ orientation = kp.NumericProperty(1) buffer = kp.ObjectProperty(None, allownone=True) mirror = kp.BooleanProperty(False) angle = kp.NumericProperty(0) def load_image(self, buffer, source, orientation): self.orientation = orientation self.buffer = buffer # triggers actual loading self.source = source # make visible self.opacity = 1 def clear_image(self): # make invisible self.opacity = 0 def set_size(self, size): width, height = size # swap dimensions if the image is rotated self.size = (height, width) if self.orientation in (5,6,7,8) else (width, height) # ensure the correct positioning via the center self.center = width / 2.0, height / 2.0 # note that the widget's bounding box will be overlapping with other grid # cells, however the content will be confined in the correct grid box. def on_orientation(self, wx, orientation): if orientation in (2, 4, 5, 7): # Mirror self.mirror = True if orientation in (3, 4): # Rotate 180deg self.angle = 180 elif orientation in (5, 6): # Rotate clockwise, 90 deg self.angle = -90 elif orientation in (7, 8): # Rotate counter-clockwise, 90 deg self.angle = 90 else: self.angle = 0 self.mirror = False @staticmethod def loader(buffer, fname): # helper method to load the image from a raw buffer with buffer as buf: return ImageLoaderTagit(filename=fname, inline=True, rawdata=buf) def _load_source(self, *args): # overwrites method from parent class source = self.source if not source: if self._coreimage is not None: self._coreimage.unbind(on_texture=self._on_tex_change) self._coreimage.unbind(on_load=self.post_source_load) self.texture = None self._coreimage = None else: if self._coreimage is not None: # unbind old image self._coreimage.unbind(on_load=self._on_source_load) self._coreimage.unbind(on_error=self._on_source_error) self._coreimage.unbind(on_texture=self._on_tex_change) del self._coreimage self._coreimage = None self._coreimage = image = Loader.image(self.source, nocache=self.nocache, mipmap=self.mipmap, anim_delay=self.anim_delay, load_callback=partial(self.loader, self.buffer), # mb: pass load_callback ) # bind new image image.bind(on_load=self._on_source_load) image.bind(on_error=self._on_source_error) image.bind(on_texture=self._on_tex_change) self.texture = image.texture ## config ## config.declare(('ui', 'standalone', 'browser', 'cols'), config.Unsigned(), 3, __name__, 'Browser columns', 'Default number of columns in the browser. Is at least one. Note that an odd number of columns and rows gives a more intuitive behaviour when zooming. A large value might make the program respond slowly.') config.declare(('ui', 'standalone', 'browser', 'rows'), config.Unsigned(), 3, __name__, 'Browser rows', 'Default number of rows in the grid view. Is at least one. Note that an odd number of columns and rows gives a more intuitive behaviour when zooming. A large value might make the program respond slowly.') config.declare(('ui', 'standalone', 'browser', 'fold_threshold'), config.Unsigned(), 1, __name__, 'Folding', "Define at which threshold groups will be folded. The default (1) folds every group unless it consists of only a single item (which isn't really a group anyhow).") config.declare(('ui', 'standalone', 'browser', 'gridmode'), config.Enum(Browser.GRIDMODE_GRID, Browser.GRIDMODE_LIST), Browser.GRIDMODE_GRID, __name__, 'Display style', 'The grid mode shows only the preview image of each item. The list mode shows the preview and some additional information of each item. Note that rows and cols can be specified for both options. It is recommended that they are set to the same value in grid mode, and to a single column in list mode.') config.declare(('ui', 'standalone', 'browser', 'cache_items'), config.Unsigned(), 20, __name__, 'Page pre-loading', 'Number of items that are loaded into the cache before they are actually shown. The effective number of loaded items the specified value rounded up to the page size times two (since it affects pages before and after the current one). E.g. a value of one loads the page before and after the current one irrespective of the page size. If zero, preemptive caching is disabled.') config.declare(('ui', 'standalone', 'browser', 'page_delay'), config.Unsigned(), 50, __name__, 'Page setup delay', 'Quickly scrolling through pages incurs an overhead due to loading images that will be discarded shortly afterwards. This overhead can be reduced by delaying the browser page setup for a short amount of time. If small enough the delay will not be noticable. Specify in milliseconds. Set to zero to disable the delay completely.') # FIXME: Also add select_alpha or maybe even select_style (left/right/over/under bar; overlay; recolor; others?) # FIXME: Also add cursor style config (left/right/under/over bar; borders; others?) config.declare(('ui', 'standalone', 'browser', 'select_color'), config.List(config.Unsigned()), [0,0,1], __name__, '', '') # FIXME #config.declare(('ui', 'standalone', 'browser', 'cache_all'), config.Bool(), False, # __name__, 'Cache everything', 'Cache all preview images in the background. The cache size (`ui.standalone.browser.cache_size`) should be large enough to hold the library at least once (some reserve for different resolutions is advised). Can incur a small delay when opening the library. May consume a lot of memory.') ## EOF ##