aboutsummaryrefslogtreecommitdiffstats
path: root/tagit/widgets/browser.py
blob: 17d99ed6c3df3b598bbbad51293cd1bec0d0d72f (plain)
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
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
"""

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 ##