aboutsummaryrefslogtreecommitdiffstats
path: root/tagit/widgets
diff options
context:
space:
mode:
authorMatthias Baumgartner <dev@igsor.net>2023-03-05 19:17:00 +0100
committerMatthias Baumgartner <dev@igsor.net>2023-03-05 19:17:00 +0100
commit5a325565f917c8b1d233d8e6373756c253400909 (patch)
treee6e0b475c7ab5c6a7ff4f0ea7ad1b08cecf05e68 /tagit/widgets
parente1e77797454ac747b293f589d8f2e0243173a419 (diff)
parent98e567933723c59d1d97b3a85e649cfdce514676 (diff)
downloadtagit-0.23.03.tar.gz
tagit-0.23.03.tar.bz2
tagit-0.23.03.zip
Merge branch 'develop'v0.23.03
Diffstat (limited to 'tagit/widgets')
-rw-r--r--tagit/widgets/__init__.py10
-rw-r--r--tagit/widgets/bindings.py278
-rw-r--r--tagit/widgets/browser.kv90
-rw-r--r--tagit/widgets/browser.py735
-rw-r--r--tagit/widgets/context.kv25
-rw-r--r--tagit/widgets/context.py148
-rw-r--r--tagit/widgets/dock.kv20
-rw-r--r--tagit/widgets/dock.py239
-rw-r--r--tagit/widgets/filter.kv89
-rw-r--r--tagit/widgets/filter.py334
-rw-r--r--tagit/widgets/keyboard.py142
-rw-r--r--tagit/widgets/loader.py200
-rw-r--r--tagit/widgets/session.py162
-rw-r--r--tagit/widgets/status.kv63
-rw-r--r--tagit/widgets/status.py206
15 files changed, 2741 insertions, 0 deletions
diff --git a/tagit/widgets/__init__.py b/tagit/widgets/__init__.py
new file mode 100644
index 0000000..2899f85
--- /dev/null
+++ b/tagit/widgets/__init__.py
@@ -0,0 +1,10 @@
+"""
+
+Part of the tagit module.
+A copy of the license is provided with the project.
+Author: Matthias Baumgartner, 2022
+"""
+# inner-module imports
+from .bindings import Binding
+
+## EOF ##
diff --git a/tagit/widgets/bindings.py b/tagit/widgets/bindings.py
new file mode 100644
index 0000000..3192c4e
--- /dev/null
+++ b/tagit/widgets/bindings.py
@@ -0,0 +1,278 @@
+"""Configurable keybindings.
+
+Part of the tagit module.
+A copy of the license is provided with the project.
+Author: Matthias Baumgartner, 2022
+"""
+# standard imports
+import typing
+
+# tagit imports
+from tagit.utils import errors
+
+# exports
+__all__: typing.Sequence[str] = (
+ 'Binding',
+ )
+
+
+## code ##
+
+class Binding(object):
+ """Handle keybindings.
+
+ A keybinding is a set of three constraints:
+ * Key code
+ * Inclusive modifiers
+ * Exclusive modifiers
+
+ Inclusive modifiers must be present, exclusive ones must not be present.
+ Modifiers occuring in neither of the two lists are ignored.
+
+ Modifiers are always lowercase strings. Additionally to SHIFT, CTRL and ALT,
+ the modifiers "all" and "rest" can be used.
+ "all" is a shortcut for all of the modifiers known.
+ "rest" means all modifiers not consumed by the other list yet. "rest" can
+ therefore only occur in at most one of the lists.
+
+ Usage example:
+
+ >>> # From settings, with PGUP w/o modifiers as default
+ >>> Binding.check(evt, self.cfg("bindings", "browser", "page_prev",
+ ... default=Binding.simple(Binding.PGUP, None, Binding.mALL)))
+
+ >>> # ESC or CTRL + SHIFT + a
+ >>> Binding.check(evt, Binding.multi((Binding.ESC, ),
+ ... (97, (Binding.mCTRL, Binding.mSHIFT), Binding.mREST))))
+
+ """
+
+ # Modifiers
+ mSHIFT = 'shift'
+ mCTRL = 'ctrl'
+ mALT = 'alt'
+ mCMD = 'cmd'
+ mALTGR = 'altgr'
+ mNUMLOCK = 'numlock'
+ mCAPSLOCK = 'capslock'
+ # Modifier specials
+ mALL = 'all'
+ mREST = 'rest'
+ # Special keys
+ BACKSPACE = 8
+ TAB = 9
+ ENTER = 13
+ ESC = 27
+ SPACEBAR = 32
+ DEL = 127
+ UP = 273
+ DOWN = 274
+ RIGHT = 275
+ LEFT = 276
+ INSERT = 277
+ HOME = 278
+ END = 279
+ PGUP = 280
+ PGDN = 281
+ F1 = 282
+ F2 = 283
+ F3 = 284
+ F4 = 285
+ F5 = 286
+ F6 = 287
+ F7 = 288
+ F8 = 289
+ F9 = 290
+ F10 = 291
+ F11 = 292
+ F12 = 293
+ CAPSLOCK = 301
+ RIGHT_SHIFT = 303
+ LEFT_SHIFT = 304
+ LEFT_CTRL = 305
+ RIGHT_CTRL = 306
+ ALTGR = 307
+ ALT = 308
+ CMD = 309
+
+ @staticmethod
+ def simple(code, inclusive=None, exclusive=None):
+ """Create a binding constraint."""
+ # handle strings
+ inclusive = (inclusive, ) if isinstance(inclusive, str) else inclusive
+ exclusive = (exclusive, ) if isinstance(exclusive, str) else exclusive
+
+ # handle None, ensure tuple
+ inclusive = tuple(inclusive) if inclusive is not None else tuple()
+ exclusive = tuple(exclusive) if exclusive is not None else tuple()
+
+ # handle code
+ code = Binding.str_to_key(code.lower()) if isinstance(code, str) else code
+ if code is None:
+ raise errors.ProgrammingError('invalid key code')
+
+ # build constraint
+ return [(code, inclusive, exclusive)]
+
+ @staticmethod
+ def multi(*args):
+ """Return binding for multiple constraints."""
+ return [Binding.simple(*arg)[0] for arg in args]
+
+ @staticmethod
+ def from_string(string):
+ mods = (Binding.mSHIFT, Binding.mCTRL, Binding.mALT, Binding.mCMD,
+ Binding.mALTGR, Binding.mNUMLOCK, Binding.mCAPSLOCK)
+
+ bindings = []
+ for kcombo in (itm.strip() for itm in string.split(';')):
+ strokes = [key.lower().strip() for key in kcombo.split('+')]
+
+ # modifiers; ignore lock modifiers
+ inc = [key for key in strokes if key in mods]
+ inc = [key for key in inc if key not in (Binding.mNUMLOCK, Binding.mCAPSLOCK)]
+ # key
+ code = [key for key in strokes if key not in mods]
+ if len(code) != 1:
+ raise errors.ProgrammingError('there must be exactly one key code in a keybinding')
+ code = Binding.str_to_key(code[0])
+ if code is None:
+ raise errors.ProgrammingError('invalid key code')
+
+ bindings.append((code, tuple(inc), (Binding.mREST, )))
+
+ return bindings
+
+ @staticmethod
+ def to_string(constraints):
+ values = []
+ for code, inc, exc in constraints:
+ values.append(
+ ' + '.join([m.upper() for m in inc] + [Binding.key_to_str(code)]))
+ return '; '.join(values)
+
+ @staticmethod
+ def check(stroke, constraint):
+ """Return True if *evt* matches the *constraint*."""
+ code, char, modifiers = stroke
+ all_ = {Binding.mSHIFT, Binding.mCTRL, Binding.mALT, Binding.mCMD, Binding.mALTGR}
+ for key, inclusive, exclusive in constraint:
+ inclusive, exclusive = set(inclusive), set(exclusive)
+
+ if key in (code, char): # Otherwise, we don't have to process the modifiers
+ # Handle specials
+ if 'all' in inclusive:
+ inclusive = all_
+ if 'all' in exclusive:
+ exclusive = all_
+ if 'rest' in inclusive:
+ inclusive = all_ - exclusive
+ if 'rest' in exclusive:
+ exclusive = all_ - inclusive
+
+ if (all([mod in modifiers for mod in inclusive]) and
+ all([mod not in modifiers for mod in exclusive])):
+ # Code and modifiers match
+ return True
+
+ # No matching constraint found
+ return False
+
+ @staticmethod
+ def key_to_str(code, default='?'):
+ if isinstance(code, str):
+ return code
+
+ if 32 <= code and code <= 226 and code != 127:
+ return chr(code)
+
+ return {
+ Binding.BACKSPACE : 'BACKSPACE',
+ Binding.TAB : 'TAB',
+ Binding.ENTER : 'ENTER',
+ Binding.ESC : 'ESC',
+ Binding.SPACEBAR : 'SPACEBAR',
+ Binding.DEL : 'DEL',
+ Binding.UP : 'UP',
+ Binding.DOWN : 'DOWN',
+ Binding.RIGHT : 'RIGHT',
+ Binding.LEFT : 'LEFT',
+ Binding.INSERT : 'INSERT',
+ Binding.HOME : 'HOME',
+ Binding.END : 'END',
+ Binding.PGUP : 'PGUP',
+ Binding.PGDN : 'PGDN',
+ Binding.F1 : 'F1',
+ Binding.F2 : 'F2',
+ Binding.F3 : 'F3',
+ Binding.F4 : 'F4',
+ Binding.F5 : 'F5',
+ Binding.F6 : 'F6',
+ Binding.F7 : 'F7',
+ Binding.F8 : 'F8',
+ Binding.F9 : 'F9',
+ Binding.F10 : 'F10',
+ Binding.F11 : 'F11',
+ Binding.F12 : 'F12',
+ Binding.CAPSLOCK : 'CAPSLOCK',
+ Binding.RIGHT_SHIFT : 'RIGHT_SHIFT',
+ Binding.LEFT_SHIFT : 'LEFT_SHIFT',
+ Binding.LEFT_CTRL : 'LEFT_CTRL',
+ Binding.RIGHT_CTRL : 'RIGHT_CTRL',
+ Binding.ALTGR : 'ALTGR',
+ Binding.ALT : 'ALT',
+ Binding.CMD : 'CMD',
+ }.get(code, default)
+
+ @staticmethod
+ def str_to_key(char, default=None):
+ if isinstance(char, int):
+ return char
+
+ try:
+ # check if ascii
+ code = ord(char)
+ if 32 <= code and code <= 226:
+ return code
+ except TypeError:
+ pass
+
+ return {
+ 'BACKSPACE' : Binding.BACKSPACE,
+ 'TAB' : Binding.TAB,
+ 'ENTER' : Binding.ENTER,
+ 'ESC' : Binding.ESC,
+ 'SPACEBAR' : Binding.SPACEBAR,
+ 'DEL' : Binding.DEL,
+ 'UP' : Binding.UP,
+ 'DOWN' : Binding.DOWN,
+ 'RIGHT' : Binding.RIGHT,
+ 'LEFT' : Binding.LEFT,
+ 'INSERT' : Binding.INSERT,
+ 'HOME' : Binding.HOME,
+ 'END' : Binding.END,
+ 'PGUP' : Binding.PGUP,
+ 'PGDN' : Binding.PGDN,
+ 'F1' : Binding.F1,
+ 'F2' : Binding.F2,
+ 'F3' : Binding.F3,
+ 'F4' : Binding.F4,
+ 'F5' : Binding.F5,
+ 'F6' : Binding.F6,
+ 'F7' : Binding.F7,
+ 'F8' : Binding.F8,
+ 'F9' : Binding.F9,
+ 'F10' : Binding.F10,
+ 'F11' : Binding.F11,
+ 'F12' : Binding.F12,
+ 'CAPSLOCK' : Binding.CAPSLOCK,
+ 'RIGHT_SHIFT' : Binding.RIGHT_SHIFT,
+ 'LEFT_SHIFT' : Binding.LEFT_SHIFT,
+ 'LEFT_CTRL' : Binding.LEFT_CTRL,
+ 'RIGHT_CTRL' : Binding.RIGHT_CTRL,
+ 'ALTGR' : Binding.ALTGR,
+ 'ALT' : Binding.ALT,
+ 'CMD' : Binding.CMD,
+ }.get(char, default)
+
+## EOF ##
diff --git a/tagit/widgets/browser.kv b/tagit/widgets/browser.kv
new file mode 100644
index 0000000..63495be
--- /dev/null
+++ b/tagit/widgets/browser.kv
@@ -0,0 +1,90 @@
+#-- #:import OpenGroup tagit.actions.grouping
+
+<Browser>:
+ root: None
+ spacing: 10
+ size_hint: 1.0, 1.0
+ page_size: self.cols * self.rows
+ # must not define rows and cols
+
+<BrowserItem>:
+ is_cursor: False
+ is_selected: False
+
+
+<BrowserImage>: # This be an image
+ preview: image
+
+ AsyncBufferImage:
+ id: image
+ size_hint: None, None
+ # actual size is set in code
+ pos: 0, 0
+ # coordinates of the (actual) image's top-right corner
+ tr_x: self.center_x + self.texture.width / 2.0 if self.texture is not None else None
+ tr_y: self.center_y + self.texture.height / 2.0 if self.texture is not None else None
+
+ # FIXME: mb/port
+ #OpenGroup:
+ # root: root.browser.root
+ # # positioning:
+ # # (1) top right corner of the root (inside root)
+ # #x: root.width - self.width
+ # #y: root.height - self.height
+ # # (2) top right corner of the root (inside root)
+ # #pos_hint: {'top': 1.0, 'right': 1.0}
+ # # (3) top right corner of the image (outside the image)
+ # #x: image.tx is not None and image.tx or float('inf')
+ # #y: image.ty is not None and image.ty or float('inf')
+ # # (4) top right corner of the image (inside root, outside the image if possible)
+ # tr_x: root.width - self.width
+ # tr_y: root.height - self.height
+ # x: min(self.tr_x, image.tr_x is not None and image.tr_x or float('inf'))
+ # y: min(self.tr_y, image.tr_y is not None and image.tr_y or float('inf'))
+ #
+ # opacity: root.is_group and 1.0 or 0.0
+ # show: 'image',
+
+<BrowserDescriptionLabel@Label>:
+ halign: 'left'
+ valign: 'center'
+ text_size: self.size
+
+<BrowserDescription>: # This be a list item
+ spacer: 20
+ preview: image
+
+ AsyncBufferImage:
+ id: image
+ size_hint: None, 1
+ # actual size is set in code
+ pos: 0, 0
+
+ BrowserDescriptionLabel:
+ text: root.text
+ markup: True
+ size_hint: None, 1
+ width: root.width - image.width - root.spacer - 35
+ pos: root.height + root.spacer, 0
+
+<AsyncBufferImage>:
+ mirror: False
+ angle: 0
+ opacity: 0
+
+ canvas.before:
+ PushMatrix
+ Rotate:
+ angle: self.mirror and 180 or 0
+ origin: self.center
+ axis: (0, 1, 0)
+
+ Rotate:
+ angle: self.angle
+ origin: self.center
+ axis: (0, 0, 1)
+
+ canvas.after:
+ PopMatrix
+
+## EOF ##
diff --git a/tagit/widgets/browser.py b/tagit/widgets/browser.py
new file mode 100644
index 0000000..17d99ed
--- /dev/null
+++ b/tagit/widgets/browser.py
@@ -0,0 +1,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 ##
diff --git a/tagit/widgets/context.kv b/tagit/widgets/context.kv
new file mode 100644
index 0000000..75f5267
--- /dev/null
+++ b/tagit/widgets/context.kv
@@ -0,0 +1,25 @@
+#:import ContextMenu tagit.external.kivy_garden.contextmenu.ContextMenu
+
+<Context>:
+ menu: context_menu
+ visible: False
+ # the root widget should set these two to itself
+ bounding_box_widget: self
+ cancel_handler_widget: self
+ # button config
+ button_width: 200
+ button_height: dp(35)
+ button_show: 'text', 'image'
+
+ ContextMenu: # the actual menu
+ id: context_menu
+ visible: False
+ cancel_handler_widget: root
+ bounding_box_widget: root.bounding_box_widget
+ width: root.button_width
+
+<ContextMenuAction>:
+ width: self.parent.width if self.parent else 0
+ size_hint: 1, None
+
+## EOF ##
diff --git a/tagit/widgets/context.py b/tagit/widgets/context.py
new file mode 100644
index 0000000..2affbed
--- /dev/null
+++ b/tagit/widgets/context.py
@@ -0,0 +1,148 @@
+"""
+
+Part of the tagit module.
+A copy of the license is provided with the project.
+Author: Matthias Baumgartner, 2022
+"""
+# standard imports
+import logging
+import os
+
+# kivy imports
+from kivy.lang import Builder
+from kivy.uix.floatlayout import FloatLayout
+import kivy.properties as kp
+
+# tagit imports
+from tagit import config
+from tagit.utils.builder import InvalidFactoryName
+from tagit.actions import ActionBuilder
+from tagit.external.kivy_garden.contextmenu import ContextMenuItem, AbstractMenuItemHoverable, ContextMenuTextItem, ContextMenu
+
+# inner-module imports
+from .dock import DockBase
+
+# exports
+__all__ = ('Context', )
+
+
+## code ##
+
+logger = logging.getLogger(__name__)
+
+# load kv
+Builder.load_file(os.path.join(os.path.dirname(__file__), 'context.kv'))
+
+# classes
+class ContextMenuAction(ContextMenuItem, AbstractMenuItemHoverable):
+ """Wraps a context menu item around an action buttons."""
+ # menu requirements
+ submenu_postfix = kp.StringProperty(' ...')
+ color = kp.ListProperty([1,1,1,1])
+ # action requirements
+ action = kp.ObjectProperty(None)
+ hide_fu = kp.ObjectProperty(None)
+
+ @property
+ def content_width(self):
+ """Forward the width from the action button."""
+ if self.action is None:
+ return 0
+ return self.action.width
+
+ def set_action(self, action):
+ """Add the action button."""
+ self.add_widget(action)
+ self.action = action
+ return self
+
+ def on_touch_up(self, touch):
+ """Close the menu when an action is triggered."""
+ if self.collide_point(*touch.pos) and \
+ touch.button == 'left' and \
+ self.hide_fu is not None:
+ self.action.on_release()
+ self.hide_fu()
+ return super(ContextMenuAction, self).on_touch_up(touch)
+
+
+class Context(FloatLayout, DockBase):
+ """Context menu."""
+ root = kp.ObjectProperty(None)
+
+ def show(self, x, y):
+ """Open the menu."""
+ self.menu.show(x, y)
+
+ def on_touch_down(self, touch):
+ """Open the menu via click."""
+ if touch.button == 'right':
+ self.show(*touch.pos)
+ return super(Context, self).on_touch_down(touch)
+
+ def on_config_changed(self, session, key, value):
+ if key == ('ui', 'standalone', 'context'):
+ self.on_cfg(session, session.cfg)
+
+ def on_cfg(self, wx, cfg):
+ """Construct the menu from config."""
+ self.populate(cfg('ui', 'standalone', 'context'))
+
+ def populate(self, actions):
+ """Construct the menu."""
+ # clear old menu items
+ childs = [child for child in self.menu.children if isinstance(child, ContextMenuTextItem)]
+ childs += [child for child in self.menu.children if isinstance(child, ContextMenuAction)]
+ for child in childs:
+ self.menu.remove_widget(child)
+
+ # add new menu items
+ builder = ActionBuilder()
+ for menu, args in actions.items():
+ if menu == 'root':
+ # add directly to the context menu
+ wx = self.menu
+ else:
+ # create and add a submenu
+ head = ContextMenuTextItem(text=menu)
+ self.menu.add_widget(head)
+ wx = ContextMenu(width=self.button_width)
+ head.add_widget(wx)
+ wx._on_visible(False)
+
+ for action in args:
+ try:
+ cls = builder.get(action)
+ if action == 'SortKey':
+ # special case: add as submenu
+ btn = cls(root=self.root)
+ head = ContextMenuTextItem(text=btn.text)
+ wx.add_widget(head)
+ head.add_widget(btn.menu)
+ btn.menu._on_visible(False)
+
+ else:
+ wx.add_widget(ContextMenuAction(
+ # args to the action wrapper
+ hide_fu=self.menu.hide,
+ height=self.button_height,
+ ).set_action(cls(
+ # args to the button
+ root=self.root,
+ autowidth=False,
+ size=(self.button_width, self.button_height),
+ size_hint=(1, None),
+ show=self.button_show,
+ )))
+
+ except InvalidFactoryName:
+ logger.error(f'invalid button name: {action}')
+
+
+## config ##
+
+config.declare(('ui', 'standalone', 'context'),
+ config.Dict(config.String(), config.List(config.Enum(set(ActionBuilder.keys())))), {},
+ __name__, 'Context menu structure', 'The context menu consists of groups of actions, similar to the button dock. Each group consists of a name and a list of actions. To add actions to the menu directly, use "root" for the group name.', '{"root": ["ShowDashboard", "ShowBrowsing"], "search": ["GoBack", "GoForth"]}')
+
+## EOF ##
diff --git a/tagit/widgets/dock.kv b/tagit/widgets/dock.kv
new file mode 100644
index 0000000..4d82ac3
--- /dev/null
+++ b/tagit/widgets/dock.kv
@@ -0,0 +1,20 @@
+#:import TileDecorationVanilla tagit.tiles.decoration.TileDecorationVanilla
+
+<TileDock>:
+ cols: 3
+ rows: 3
+ decoration: TileDecorationVanilla
+ visible: False
+ tile_height: None
+ tile_width: None
+ name: ''
+
+<ButtonDock>:
+ orientation: 'lr-tb'
+ button_height: 30
+ button_width: self.button_height
+ button_show: 'image',
+ n_buttons_max: None
+ name: ''
+
+## EOF ##
diff --git a/tagit/widgets/dock.py b/tagit/widgets/dock.py
new file mode 100644
index 0000000..41ff642
--- /dev/null
+++ b/tagit/widgets/dock.py
@@ -0,0 +1,239 @@
+"""
+
+Part of the tagit module.
+A copy of the license is provided with the project.
+Author: Matthias Baumgartner, 2022
+"""
+# standard imports
+import logging
+import os
+
+# kivy imports
+from kivy.lang import Builder
+from kivy.uix.gridlayout import GridLayout
+from kivy.uix.stacklayout import StackLayout
+from kivy.uix.widget import Widget
+import kivy.properties as kp
+
+# tagit imports
+from tagit import config
+from tagit.actions import ActionBuilder
+from tagit.tiles import TileBuilder
+from tagit.utils import errors
+from tagit.utils.builder import InvalidFactoryName
+
+# inner-module imports
+from .session import ConfigAwareMixin
+
+# exports
+__all__ = ('Dock', )
+
+
+## code ##
+
+logger = logging.getLogger(__name__)
+
+# load kv
+Builder.load_file(os.path.join(os.path.dirname(__file__), 'dock.kv'))
+
+# classes
+class DockBase(Widget, ConfigAwareMixin):
+ """A Dock is a container that holds configurable items."""
+ # root reference
+ root = kp.ObjectProperty(None)
+
+ def on_cfg(self, wx, cfg):
+ """Construct the dock from config."""
+ errors.abstract()
+
+ def populate(self, config):
+ """Fill the dock with content."""
+ errors.abstract()
+
+
+class TileDock(GridLayout, DockBase):
+ """A TileDock holds a number of Tiles."""
+
+ # dock's name for loading from config
+ name = kp.StringProperty('')
+ # tile decoration
+ decoration = kp.ObjectProperty(None)
+ # tile visiblity
+ visible = kp.BooleanProperty(False)
+
+ def on_config_changed(self, session, key, value):
+ if key == ('ui', 'standalone', 'tiledocks'):
+ self.on_cfg(session, session.cfg)
+
+ def on_cfg(self, wx, cfg):
+ """Construct the Tiles from the config item matching dock's name."""
+ if self.name != '':
+ self.populate(cfg('ui', 'standalone', 'tiledocks').get(self.name, {}))
+ # FIXME: Since dictionaries are not ordered, the tiles might change
+ # their position at every application start. Switching to a list would
+ # solve this issue. E.g. [{tile: 'tile name', **kwargs}]
+
+ def populate(self, tiles):
+ """Construct the Tiles."""
+ # clear old items
+ self.clear_widgets()
+
+ # add new items
+ n_tiles_max = self.cols * self.rows
+ builder = TileBuilder()
+ for idx, tid in enumerate(sorted(tiles)):
+ if idx >= n_tiles_max:
+ logger.warn(f'number of tiles exceeds space ({len(tiles)} > {n_tiles_max})')
+ break
+
+ try:
+ kwargs = tiles[tid]
+ tile = builder.build(tid, root=self.root, **kwargs)
+ self.add_widget(self.decoration(client=tile))
+ except InvalidFactoryName:
+ logger.error(f'invalid tile name: {tid}')
+
+ # create and attach widgets before setting visibility
+ # to ensure that the widget initialization has finished.
+ self.on_visible(self, self.visible)
+
+ def on_size(self, *args):
+ # FIXME: If dashboard is loaded, resizing the window becomes painfully slow.
+ # Something to do with the code here, e.g. delayed sizing?
+ for child in self.children:
+ # TODO: Allow default_size or tile_size to specify relative sizes (<1)
+ # determine size
+ width = self.tile_width
+ width = child.default_size[0] if width is None else width
+ #width = self.width if width is None and self.size_hint_x is None else width
+ height = self.tile_height
+ height = child.default_size[1] if height is None else height
+ #height = self.height if height is None and self.size_hint_y is None else height
+ size = width if width is not None else 1, height if height is not None else 1
+ size_hint = None if width is not None else 1, None if height is not None else 1
+ # set size; will be propagated from the decorator to the client
+ child.size = size
+ child.size_hint = size_hint
+
+ def on_visible(self, wx, visible):
+ """Propagate visibility update to Tiles."""
+ for child in self.children:
+ child.client.visible = visible
+
+ # FIXME: move events in the browser are only triggered if the move event is also
+ # handled here with an empty body (no super!).
+ # No idea why this happens (e.g. doing it in desktop or tab doesn't work).
+ def on_touch_move(self, touch):
+ pass
+
+
+class ButtonDock(StackLayout, DockBase):
+ """A ButtonDock holds a number of Actions."""
+
+ # dock's name for loading from config
+ name = kp.StringProperty('')
+
+ def on_config_changed(self, session, key, value):
+ if key == ('ui', 'standalone', 'buttondocks'):
+ self.on_cfg(session, session.cfg)
+
+ def on_cfg(self, wx, cfg):
+ """Construct the Actions from config item matching the dock's name."""
+ if self.name != '':
+ # name is empty if created via the Buttons tile
+ self.populate(cfg('ui', 'standalone', 'buttondocks').get(self.name, []))
+
+ def populate(self, actions):
+ """Construct the Actions."""
+ # clear old items
+ self.clear_widgets()
+
+ # add new items
+ n_buttons_max = float('inf') if self.n_buttons_max is None else self.n_buttons_max
+ builder = ActionBuilder()
+ for idx, action in enumerate(actions):
+ if idx >= n_buttons_max:
+ logger.warn(f'number of buttons exceeds space ({len(actions)} > {n_buttons_max})')
+ break
+
+ try:
+ self.add_widget(builder.build(action,
+ root=self.root,
+ size=(self.button_width, self.button_height),
+ show=self.button_show,
+ autowidth=False,
+ ))
+ except InvalidFactoryName:
+ logger.error(f'invalid button name: {action}')
+
+
+class KeybindDock(DockBase):
+ """The KeybindDock holds a number of invisible Actions that can be triggered by key presses."""
+
+ def on_config_changed(self, session, key, value):
+ if key == ('ui', 'standalone', 'keytriggers'):
+ self.on_cfg(session, session.cfg)
+
+ def on_cfg(self, wx, cfg):
+ """Construct the Actions from config."""
+ self.populate(cfg('ui', 'standalone', 'keytriggers'))
+
+ def populate(self, actions):
+ """Construct the Actions."""
+ # clear old items
+ self.clear_widgets()
+
+ # add new items
+ builder = ActionBuilder()
+ for action in actions:
+ try:
+ self.add_widget(builder.build(
+ action,
+ root=self.root,
+ # process key events only
+ touch_trigger=False,
+ key_trigger=True,
+ # no need to specify show (default is empty)
+ ))
+
+ except InvalidFactoryName:
+ logger.error(f'invalid button name: {action}')
+
+
+## config ##
+
+config.declare(('ui', 'standalone', 'keytriggers'),
+ config.List(config.Enum(set(ActionBuilder.keys()))), [],
+ __name__, 'Key triggers',
+ 'Actions that can be triggered by a key but have no visible button', '')
+
+config.declare(('ui', 'standalone', 'tiledocks'),
+ config.Dict(config.String(), config.Dict(config.String(), config.Dict(config.String(), config.Any()))), {},
+ __name__, 'Tile docks', '''Tiles can be placed in several locations of the UI. A tile usually displays some information about the current program state, such as information about the library in general, visible or selected items, etc.
+
+The configuration of a tile consists the its name as string and additional parameters to that tile as a dict. A tile dock is configured by a dictionary with the tile names as key and their parameters as value:
+
+{
+ "Hints": {},
+ "ButtonDock": {"buttons: ["Save", "SaveAs", "Index"]}
+}
+
+The order of the items in the UI is generally the same as in the config dict.
+
+To show a list of available tiles, execute:
+
+$ tagger info tile
+
+''')
+
+config.declare(('ui', 'standalone', 'buttondocks'),
+ config.Dict(config.String(), config.List(config.Enum(set(ActionBuilder.keys())))), {},
+ __name__, 'Buttons', '''Every possible action in the UI is triggered via a button. Hence, buttons are found in various places in the UI, organized in button docks. Each dock is identified by name and lists the names of the buttons it contains.
+
+To show a list of available buttons, execute:
+
+$ tagger info action
+
+''')
+
+## EOF ##
diff --git a/tagit/widgets/filter.kv b/tagit/widgets/filter.kv
new file mode 100644
index 0000000..5407610
--- /dev/null
+++ b/tagit/widgets/filter.kv
@@ -0,0 +1,89 @@
+#:import SearchmodeSwitch tagit.actions.filter
+#:import AddToken tagit.actions.filter
+#-- #:import SortKey tagit.actions.search
+
+<Filter>:
+ root: None
+ orientation: 'horizontal'
+ spacing: 5
+ tokens: tokens
+
+ Widget:
+ size_hint_x: None
+ width: 5
+
+ ScrollView:
+ do_scroll_x: True
+ do_scroll_y: False
+ size_hint: 1, 1
+
+ BoxLayout:
+ orientation: 'horizontal'
+ spacing: 10
+ id: tokens
+ size_hint: None, None
+ height: 35
+ width: self.minimum_width
+ # Tokens will be inserted here
+
+ AddToken:
+ show: 'image',
+ root: root.root
+
+ # FIXME: Temporarily disabled
+ #SearchmodeSwitch:
+ # show: 'image',
+ # root: root.root
+
+ #SortKey:
+ # show: 'image',
+ # root: root.root
+
+ SortOrder:
+ show: 'image',
+ root: root.root
+
+ ButtonDock:
+ root: root.root
+ name: 'filter'
+ orientation: 'lr-tb'
+ # space for two buttons
+ width: 2*30 + 5
+ spacing: 5
+ size_hint: None, None
+ height: 35
+ button_height: 30
+ button_show: 'image',
+
+<Avatar@Label>:
+ active: False
+
+<ShingleText@Label>:
+ active: False
+
+<Shingle>:
+ orientation: 'horizontal'
+ label: tlabel
+ size_hint: None, None
+ width: self.minimum_width
+ height: 30
+
+ Avatar:
+ id: avatar
+ size_hint: None, None
+ text: root.avatar
+ width: self.parent.height
+ height: self.parent.height
+ active: root.active
+
+ ShingleText:
+ id: tlabel
+ text: root.text
+ active: root.active
+ width: (self.texture_size[0] + dp(20)) if self.text != '' else 0
+ size_hint_x: None
+
+<Addressbar>:
+ multiline: False
+
+## EOF ##
diff --git a/tagit/widgets/filter.py b/tagit/widgets/filter.py
new file mode 100644
index 0000000..1382c43
--- /dev/null
+++ b/tagit/widgets/filter.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 logging
+import os
+
+# kivy imports
+from kivy.clock import Clock
+from kivy.config import Config as KivyConfig
+from kivy.lang import Builder
+from kivy.uix.behaviors import ButtonBehavior
+from kivy.uix.behaviors import FocusBehavior
+from kivy.uix.boxlayout import BoxLayout
+from kivy.uix.image import Image
+from kivy.uix.textinput import TextInput
+import kivy.properties as kp
+
+# tagit imports
+from tagit import config
+from tagit.utils import bsfs, errors, ns
+from tagit.utils.bsfs import ast, matcher
+
+# inner-module imports
+from .session import ConfigAwareMixin
+
+# exports
+__all__ = ('Filter', )
+
+
+## code ##
+
+logger = logging.getLogger(__name__)
+
+# load kv
+Builder.load_file(os.path.join(os.path.dirname(__file__), 'filter.kv'))
+
+# classes
+class Filter(BoxLayout, ConfigAwareMixin):
+ """
+ A filter tracks a sequence of searches building on top of each other. Each
+ item in that sequence is defined by a part of the overall search query
+ (token). In addition, the filter also tracks the viewport at each point in
+ the sequence (frames).
+
+ In addition, the sequence can be navigated back-and-forth, so that the
+ current search includes a number of items, starting at the front, but not
+ necessarily all. Hence, some tokens are present in the current
+ search (head), while others are not (tail).
+ """
+ # root reference
+ root = kp.ObjectProperty(None)
+
+ # change notification
+ changed = kp.BooleanProperty(False)
+ run_search = kp.BooleanProperty(False)
+
+ # appearance
+ MODE_SHINGLES = 'shingles'
+ MODE_ADDRESS = 'address'
+ searchmode = kp.OptionProperty(MODE_SHINGLES, options=[MODE_SHINGLES, MODE_ADDRESS])
+
+ '''
+ To track head, tail, tokens, and frames, four properties are used for
+ the relevant pairwise combinations.
+
+ For heads, the frame is the last known viewport before applying the
+ next filter token. I.e. f_head[1] corresponds to the search including
+ tokens t_head[:1]. The viewport of the current search is maintained
+ in the browser.
+
+ For tails, the frame is the last viewport before switching to the previous
+ filter token. I.e. f_tail[1] corresponds to the search including
+ tokens t_tail[:2] (i.e. the lists are aligned).
+
+ Consider the following scheme.
+ The current search is indicated by the "v". The first search includes
+ no tokens (all items). Note the offset between tokens and frames in
+ the head part.
+
+ v
+ view 0 1 2 3 4
+ token - 0 1 2 3 0 1
+ frame 0 1 2 3 - 0 1
+
+ Although the lists are not necessarily aligned, they always have to have
+ the same size. This constraint is enforced.
+
+ '''
+ # tokens
+ t_head = kp.ListProperty()
+ t_tail = kp.ListProperty()
+
+ # frames
+ f_head = kp.ListProperty()
+ f_tail = kp.ListProperty()
+
+ # sort
+ #sortkey = kp.ObjectProperty(partial(ast.NumericalSort, 'time'))
+ sortkey = kp.ObjectProperty(None) # FIXME: mb/port
+ sortdir = kp.BooleanProperty(False) # False means ascending
+
+
+ ## exposed methods
+
+ def get_query(self):
+ query = bsfs.ast.filter.And(self.t_head[:]) if len(self.t_head) > 0 else None
+ sort = None
+ return query, sort
+ # FIXME: mb/port.parsing
+ query = ast.AND(self.t_head[:]) if len(self.t_head) else None
+ # sort order is always set to False so that changing the sort order
+ # won't trigger a new query which can be very expensive. The sort
+ # order is instead applied in uix.kivy.actions.search.Search.
+ sort = self.sortkey(False) if self.sortkey is not None else None
+ return query, sort
+
+ def abbreviate(self, token):
+ # FIXME: Return image
+ matches = matcher.Filter()
+ if matches(token, ast.filter.Any(ns.bse.tag, ast.filter.Any(ns.bst.label, matcher.Any()))):
+ # tag token
+ return 'T'
+ if matches(token, matcher.Partial(ast.filter.Is)) or \
+ matches(token, ast.filter.Or(matcher.Rest(matcher.Partial(ast.filter.Is)))):
+ # exclusive token
+ return '='
+ if matches(token, ast.filter.Not(matcher.Partial(ast.filter.Is))) or \
+ matches(token, ast.filter.Not(ast.filter.Or(matcher.Rest(matcher.Partial(ast.filter.Is))))):
+ # reduce token
+ return '—'
+ if matches(token, ast.filter.Any(ns.bse.group, matcher.Any())):
+ # group token
+ return 'G'
+ if matches(token, ast.filter.Any(matcher.Partial(ast.filter.Predicate), matcher.Any())):
+ # generic token
+ #return token.predicate.predicate.get('fragment', '?').title()[0]
+ return 'P'
+ return '?'
+
+ def tok_label(self, token):
+ matches = matcher.Filter()
+ if matches(token, ast.filter.Any(ns.bse.tag, ast.filter.Any(ns.bst.label, matcher.Any()))):
+ # tag token
+ return self.root.session.filter_to_string(token)
+ if matches(token, matcher.Partial(ast.filter.Is)) or \
+ matches(token, ast.filter.Not(matcher.Partial(ast.filter.Is))):
+ return '1'
+ if matches(token, ast.filter.Or(matcher.Rest(matcher.Partial(ast.filter.Is)))):
+ return str(len(token))
+ if matches(token, ast.filter.Not(ast.filter.Or(matcher.Rest(matcher.Partial(ast.filter.Is))))):
+ return str(len(token.expr))
+ if matches(token, ast.filter.Any(matcher.Partial(ast.filter.Predicate), matcher.Any())):
+ # generic token
+ #return self.root.session.filter_to_string(token)
+ return token.predicate.predicate.get('fragment', '')
+ return ''
+
+ def show_address_once(self):
+ """Single-shot address mode without changing the search mode."""
+ self.tokens.clear_widgets()
+ searchbar = Addressbar(self.t_head, root=self.root)
+ self.tokens.add_widget(searchbar)
+ searchbar.focus = True
+
+
+ ## initialization
+
+ def on_config_changed(self, session, key, value):
+ if key == ('ui', 'standalone', 'filter', 'searchbar'):
+ self.on_cfg(session, session.cfg)
+
+ def on_cfg(self, wx, cfg):
+ with self:
+ self.searchmode = cfg('ui', 'standalone', 'filter', 'searchbar')
+
+ ## filter as context
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc_value, traceback):
+ if not(len(self.t_head) == len(self.f_head)):
+ raise errors.ProgrammingError('head sizes differ')
+ if not(len(self.t_tail) == len(self.f_tail)):
+ raise errors.ProgrammingError('tail sizes differ')
+
+ # issue redraw
+ if self.changed:
+ self.redraw()
+ # issue search
+ if self.run_search:
+ self.root.trigger('Search')
+
+ def redraw(self):
+ self.tokens.clear_widgets()
+ if self.searchmode == self.MODE_ADDRESS:
+ # add address bar
+ self.tokens.add_widget(Addressbar(self.t_head, root=self.root))
+
+ elif self.searchmode == self.MODE_SHINGLES:
+ # add shingles
+ for tok in self.t_head + self.t_tail:
+ self.tokens.add_widget(
+ Shingle(
+ tok,
+ active=(tok in self.t_head),
+ avatar=self.abbreviate(tok),
+ text=self.tok_label(tok),
+ root=self.root
+ ))
+
+ ## property access
+
+ def on_t_head(self, sender, t_head):
+ self.changed = True
+ self.run_search = True
+
+ def on_t_tail(self, sender, t_tail):
+ self.changed = True
+
+ def on_searchmode(self, sender, mode):
+ self.changed = True
+
+ def on_sortdir(self, sender, sortdir):
+ self.run_search = True
+
+ def on_sortkey(self, sender, sortkey):
+ self.run_search = True
+
+
+class FilterAwareMixin(object):
+ """Tile that binds to the filter."""
+ filter = None
+ def on_root(self, wx, root):
+ root.bind(filter=self.on_filter)
+ if root.filter is not None:
+ # initialize with the current filter
+ # Going through the event dispatcher ensures that the object
+ # is initialized properly before on_filter is called.
+ Clock.schedule_once(lambda dt: self.on_filter(root, root.filter))
+
+ def on_filter(self, sender, filter):
+ pass
+
+
+class Shingle(BoxLayout):
+ """A sequence of filter tokens. Tokens can be edited individually."""
+ # root reference
+ root = kp.ObjectProperty(None)
+
+ # content
+ active = kp.BooleanProperty(False)
+ text = kp.StringProperty('')
+ avatar = kp.StringProperty('')
+
+ # touch behaviour
+ _single_tap_action = None
+
+ def __init__(self, token, **kwargs):
+ super(Shingle, self).__init__(**kwargs)
+ self.token = token
+
+ def remove(self, *args, **kwargs):
+ """Remove shingle."""
+ self.root.trigger('RemoveToken', self.token)
+
+ def on_touch_down(self, touch):
+ """Edit shingle when touched."""
+ if self.collide_point(*touch.pos):
+ if touch.is_double_tap: # edit filter
+ # ignore touch, such that the dialogue
+ # doesn't loose the focus immediately after open
+ if self._single_tap_action is not None:
+ self._single_tap_action.cancel()
+ self._single_tap_action = None
+ FocusBehavior.ignored_touch.append(touch)
+ self.root.trigger('EditToken', self.token)
+ return True
+ else: # jump to filter
+ # delay executing the action until we're sure it's not a double tap
+ self._single_tap_action = Clock.schedule_once(
+ lambda dt: self.root.trigger('JumpToToken', self.token),
+ KivyConfig.getint('postproc', 'double_tap_time') / 1000)
+ return True
+
+ return super(Shingle, self).on_touch_down(touch)
+
+class Addressbar(TextInput):
+ """An address bar where a search query can be entered and edited.
+ Edits are accepted by pressing Enter and rejected by pressing Esc.
+ """
+ # root reference
+ root = kp.ObjectProperty()
+
+ def __init__(self, tokens, **kwargs):
+ super(Addressbar, self).__init__(**kwargs)
+ self.text = self.root.session.filter_to_string(bsfs.ast.filter.And(tokens))
+ self._last_text = self.text
+
+ def on_text_validate(self):
+ """Accept text as search string."""
+ self.root.trigger('SetToken', self.text)
+ self._last_text = self.text
+
+ def on_keyboard(self, *args, **kwargs):
+ """Block key propagation to other widgets."""
+ return True
+
+ def on_focus(self, wx, focus):
+ from kivy.core.window import Window
+ if focus:
+ # fetch keyboard
+ Window.bind(on_keyboard=self.on_keyboard)
+ # keep a copy of the current text
+ self._last_text = self.text
+ else:
+ # release keyboard
+ Window.unbind(on_keyboard=self.on_keyboard)
+ # set last accepted text
+ self.text = self._last_text
+
+
+## config ##
+
+config.declare(('ui', 'standalone', 'filter', 'searchbar'),
+ config.Enum('shingles', 'address'), 'shingles',
+ __name__, 'Searchbar mode', 'Show either list of shingles, one per search token, or a freely editable address bar.')
+
+## EOF ##
diff --git a/tagit/widgets/keyboard.py b/tagit/widgets/keyboard.py
new file mode 100644
index 0000000..2cae7d6
--- /dev/null
+++ b/tagit/widgets/keyboard.py
@@ -0,0 +1,142 @@
+"""
+
+Part of the tagit module.
+A copy of the license is provided with the project.
+Author: Matthias Baumgartner, 2022
+"""
+# kivy imports
+from kivy.uix.widget import Widget
+import kivy.properties as kp
+
+# exports
+__all__ = []
+
+
+## code ##
+
+class Keyboard(Widget):
+ """Captures key events and turns them into simplified events.
+ Keeps a record of currently pressed modifiers (CTRL, SHIFT, etc.).
+ """
+
+ # modifiers
+ MODIFIERS_NONE = 0b00000 # 0
+ MODIFIERS_CTRL = 0b00001 # 1
+ MODIFIERS_SHIFT = 0b00010 # 2
+ MODIFIERS_ALT = 0b00100 # 4
+ MODIFIERS_ALTGR = 0b01000 # 8
+ MODIFIERS_CMD = 0b10000 # 16
+
+ # modifier keymaps
+ keymap = {
+ 303: MODIFIERS_SHIFT, # right shift
+ 304: MODIFIERS_SHIFT, # left shift
+ 305: MODIFIERS_CTRL, # left ctrl
+ 306: MODIFIERS_CTRL, # right ctrl
+ 307: MODIFIERS_ALTGR,
+ 308: MODIFIERS_ALT,
+ 309: MODIFIERS_CMD, # a.k.a. windows key
+ }
+
+ modemap = {
+ MODIFIERS_SHIFT: (303, 304),
+ MODIFIERS_CTRL: (305, 306),
+ MODIFIERS_ALTGR: (307, ),
+ MODIFIERS_ALT: (308, ),
+ MODIFIERS_CMD: (309, ),
+ }
+
+ # current mode
+ mode = kp.NumericProperty(MODIFIERS_NONE)
+
+ # state access via properties
+
+ @property
+ def none_pressed(self):
+ return self.mode & self.MODIFIERS_NONE
+
+ @property
+ def ctrl_pressed(self):
+ return self.mode & self.MODIFIERS_CTRL
+
+ @property
+ def shift_pressed(self):
+ return self.mode & self.MODIFIERS_SHIFT
+
+ @property
+ def alt_pressed(self):
+ return self.mode & self.MODIFIERS_ALT
+
+ @property
+ def altgr_pressed(self):
+ return self.mode & self.MODIFIERS_ALTGR
+
+ @property
+ def cmd_pressed(self):
+ return self.mode & self.MODIFIERS_CMD
+
+
+ ## outbound events
+
+ __events__ = ('on_press', 'on_release')
+
+ def on_press(sender, evt):
+ """Key press event prototype."""
+ pass
+
+ def on_release(sender, evt):
+ """Key release event prototype."""
+ pass
+
+
+ ## event rewriting
+
+ def __init__ (self, **kwargs):
+ super(Keyboard, self).__init__(**kwargs)
+ # keybindings
+ from kivy.core.window import Window
+ Window.bind(on_key_up=self.on_key_up)
+ Window.bind(on_key_down=self.on_key_down)
+ Window.bind(on_keyboard=self.on_keyboard)
+
+ def __del__(self):
+ from kivy.core.window import Window
+ Window.unbind(on_key_up=self.on_key_up)
+ Window.unbind(on_key_down=self.on_key_down)
+ Window.unbind(on_keyboard=self.on_keyboard)
+
+ def on_key_up(self, wx, key, scancode):
+ """Record modifier release."""
+ mode = self.keymap.get(key, self.MODIFIERS_NONE)
+ self.mode -= self.mode & mode
+ self.dispatch('on_release', key)
+
+ def on_key_down(self, wx, key, scancode, char, modifiers):
+ """Record modifiers press."""
+ mode = self.keymap.get(key, self.MODIFIERS_NONE)
+ self.mode |= mode
+
+ def on_keyboard(self, wx, key, scancode, char, modifiers):
+ """Forward key presses Handles keybindings. Is called when a key press is detected.
+
+ *key* : ASCII or ASCII-like value
+ *scancode* : Key code returned by the input provider (e.g. keyboard)
+ *char* : String representation (if A-Z, a-z)
+ *modifiers* : 'ctrl', 'shift', 'alt', or any combination thereof, if pressed
+
+ """
+ if False:
+ # print key event for debugging
+ print(f"""Keybindings: Event
+ Key : {key}
+ Scancode : {scancode}
+ Codepoint : {char}
+ Modifiers : {modifiers}
+ """)
+
+ # forward compact event to widgets
+ self.dispatch('on_press', (key, char, modifiers))
+ # prevent further event propagation
+ return True
+
+## EOF ##
diff --git a/tagit/widgets/loader.py b/tagit/widgets/loader.py
new file mode 100644
index 0000000..9c0ffaf
--- /dev/null
+++ b/tagit/widgets/loader.py
@@ -0,0 +1,200 @@
+"""
+
+Part of the tagit module.
+A copy of the license is provided with the project.
+Author: Matthias Baumgartner, 2022
+"""
+# standard imports
+import time
+import typing
+
+# kivy imports
+from kivy.cache import Cache
+from kivy.clock import Clock
+from kivy.compat import queue
+from kivy.loader import _Worker, LoaderThreadPool, ProxyImage, LoaderBase
+
+# exports
+__all__: typing.Sequence[str] = (
+ 'Loader',
+ )
+
+
+## code ##
+
+class _ThreadPool(object):
+ """Pool of threads consuming tasks from a queue.
+ Identical to kivy.loader._ThreadPool except for the queue type."""
+ def __init__(self, num_threads):
+ super(_ThreadPool, self).__init__()
+ self.running = True
+ self.tasks = queue.LifoQueue() # mb: replace Queue with LifoQueue
+ for _ in range(num_threads):
+ _Worker(self, self.tasks)
+
+ def add_task(self, func, *args, **kargs):
+ self.tasks.put((func, args, kargs))
+
+ def stop(self):
+ self.running = False
+ self.tasks.join()
+
+
+class TagitImageLoader(LoaderThreadPool):
+ """Threaded Loader that prioritises recentness.
+ This is useful if a user skips through browser pages because then the preview loading
+ finishes only after the user has already switched to the next page. Instead of waiting
+ until all images up to the target page were loaded, prioritsation makes more recent
+ images to load first.
+
+ Mostly copied from kivy.loader.Loader.
+ """
+ def start(self):
+ LoaderBase.start(self) # mb: skip LoaderThreadPool.start
+ self.pool = _ThreadPool(self._num_workers)
+ Clock.schedule_interval(self.run, 0)
+
+ def image(self, filename, load_callback=None, post_callback=None,
+ **kwargs):
+ data = Cache.get('kv.loader', filename)
+ if data not in (None, False):
+ # found image, if data is not here, need to reload.
+ return ProxyImage(data,
+ loading_image=self.loading_image,
+ loaded=True, **kwargs)
+
+ client = ProxyImage(self.loading_image,
+ loading_image=self.loading_image, **kwargs)
+ self._client.append((filename, client))
+
+ if data is None:
+ # if data is None, this is really the first time
+ self._q_load.appendleft({
+ 'filename': filename,
+ 'load_callback': load_callback,
+ 'post_callback': post_callback,
+ 'request_time': Clock.get_time(), # mb: also pass time of original request
+ 'kwargs': kwargs})
+ if not kwargs.get('nocache', False):
+ Cache.append('kv.loader', filename, False)
+ self._start_wanted = True
+ self._trigger_update()
+ else:
+ # already queued for loading
+ pass
+
+ return client
+
+ def _clear(self):
+ if self.pool is not None:
+ tbr = set()
+
+ # clear loader queue
+ while len(self._q_load):
+ kargs = self._q_load.pop()
+ tbr.add(kargs['filename'])
+
+ # clear task queue
+ while not self.pool.tasks.empty():
+ func, args, kargs = self.pool.tasks.get()
+ if len(args) and 'filename' in args[0]:
+ tbr.add(args[0]['filename'])
+ self.pool.tasks.task_done()
+
+ # remove spurious entries from cache
+ for key in tbr:
+ # remove directly from Cache if _clear is run from the main thread
+ Cache.remove('kv.loader', key)
+ # otherwise go via _q_done
+ #self._q_done.appendleft(key, None, 0))
+
+ # remove spurious clients
+ for key in ((name, client) for name, client in self._client if name in tbr):
+ self._client.remove(key)
+
+ def clear(self):
+ """Empty the queue without loading the images."""
+ # execute in main thread
+ self._clear()
+ # schedule as event (no real benefit)
+ #if self.pool is not None:
+ # self.pool.add_task(self._clear)
+
+ def _load(self, kwargs):
+ while len(self._q_done) >= (
+ self.max_upload_per_frame * self._num_workers):
+ time.sleep(0.1)
+
+ self._wait_for_resume()
+
+ filename = kwargs['filename']
+ load_callback = kwargs['load_callback']
+ post_callback = kwargs['post_callback']
+ try:
+ proto = filename.split(':', 1)[0]
+ except:
+ # if blank filename then return
+ return
+ if load_callback is not None:
+ data = load_callback(filename)
+ elif proto in ('http', 'https', 'ftp', 'smb'):
+ data = self._load_urllib(filename, kwargs['kwargs'])
+ else:
+ data = self._load_local(filename, kwargs['kwargs'])
+
+ if post_callback:
+ data = post_callback(data)
+
+ # mb: also pass request_time
+ self._q_done.appendleft((filename, data, kwargs['request_time']))
+ self._trigger_update()
+
+ def _update(self, *largs):
+ # want to start it ?
+ if self._start_wanted:
+ if not self._running:
+ self.start()
+ self._start_wanted = False
+
+ # in pause mode, don't unqueue anything.
+ if self._paused:
+ self._trigger_update()
+ return
+
+ for x in range(self.max_upload_per_frame):
+ try:
+ filename, data, timestamp = self._q_done.pop()
+ except IndexError:
+ return
+
+ # create the image
+ image = data # ProxyImage(data)
+
+ if image is None: # mb: discard items
+ # remove cache and client entries
+ Cache.remove('kv.loader', filename)
+ for key in ((name, client) for name, client in self._client if name == filename):
+ self._client.remove(key)
+ continue
+
+ if not image.nocache:
+ Cache.append('kv.loader', filename, image)
+ # mb: fix cache times
+ Cache._objects['kv.loader'][filename]['lastaccess'] = timestamp
+ Cache._objects['kv.loader'][filename]['timestamp'] = timestamp
+
+ # update client
+ for c_filename, client in self._client[:]:
+ if filename != c_filename:
+ continue
+ # got one client to update
+ client.image = image
+ client.loaded = True
+ client.dispatch('on_load')
+ self._client.remove((c_filename, client))
+
+ self._trigger_update()
+
+Loader = TagitImageLoader()
+
+## EOF ##
diff --git a/tagit/widgets/session.py b/tagit/widgets/session.py
new file mode 100644
index 0000000..30dfe51
--- /dev/null
+++ b/tagit/widgets/session.py
@@ -0,0 +1,162 @@
+"""
+
+Part of the tagit module.
+A copy of the license is provided with the project.
+Author: Matthias Baumgartner, 2022
+"""
+# standard imports
+import os
+import typing
+
+# kivy imports
+from kivy.clock import Clock
+from kivy.uix.widget import Widget
+import kivy.properties as kp
+
+# tagit imports
+from tagit import parsing
+from tagit.config.loader import load_settings
+from tagit.utils import bsfs
+
+# exports
+__all__: typing.Sequence[str] = (
+ 'ConfigAwareMixin',
+ 'Session',
+ )
+
+
+## code ##
+
+class Session(Widget):
+ storage = kp.ObjectProperty(None)
+ cfg = kp.ObjectProperty(None)
+
+ __events__ = ('on_storage_modified', 'on_predicate_modified', 'on_config_changed')
+
+ def __init__(self, cfg, storage, log, **kwargs):
+ super(Session, self).__init__(**kwargs)
+ self.cfg = cfg
+ self.storage = storage
+ self.log = log
+ # derived members
+ self.filter_from_string = parsing.filter.FromString(self.storage.schema)
+ self.filter_to_string = parsing.filter.ToString(self.storage.schema)
+ #self.sort_from_string = parsing.Sort(self.storage.schema) # FIXME: mb/port/parsing
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc_value, traceback):
+ pass
+
+ def clone(self, cfg):
+ """Clone the session and load the clone."""
+ # clone storages to new location
+ liburi = cfg('session', 'paths', 'library')
+ numuri = cfg('session', 'paths', 'numerical')
+ storage = Broker.Clone(self.storage, liburi, numuri, None, cfg)
+ log = load_log(cfg) # not cloned
+ # switch to new storage
+ self.cfg = cfg
+ self.log = log
+ self.storage = storage
+
+ def load(self, cfg):
+ """Load the session from configuration *cfg*."""
+ #self.log = load_log(cfg) # FIXME: mb/port
+ # initialize storages from config
+ # open BSFS storage
+ store = bsfs.Open(cfg('session', 'bsfs'))
+ # check storage schema
+ with open(resource_find('required_schema.nt'), 'rt') as ifile:
+ required_schema = bsfs.schema.from_string(ifile.read())
+ if not required_schema.consistent_with(store.schema):
+ raise Exception("The storage's schema is incompatible with tagit's requirements")
+ if not required_schema <= store.schema:
+ store.migrate(required_schema | store.schema)
+
+ # replace current with new storage
+ self.storage = store
+
+ def update_settings_key(self, key, value):
+ # change setting
+ self.cfg.set(key, value)
+
+ # update settings file
+ # FIXME: file_connected is also true if it loaded config from user home!
+ if self.cfg.file_connected() and self.cfg('storage', 'config', 'write_through'):
+ # store only difference to baseline (i.e. session config)
+ local_config = self.cfg.diff(load_settings())
+ local_config.save()
+
+ # trigger update event
+ self.dispatch('on_config_changed', key, value)
+
+ def on_config_changed(sender, key, value):
+ """Event prototype."""
+ pass
+
+ def on_storage(self, wx, storage):
+ # fire event if the storage was replaced
+ self.dispatch('on_storage_modified')
+
+ def on_storage_modified(sender):
+ """Event prototype.
+ Triggered when items are added or removed
+ """
+ pass
+
+ def on_predicate_modified(sender, predicate, objects, diff):
+ """Event prototype.
+ Triggered when a predicate to one or several objects have been changed.
+ """
+ pass
+
+class StorageAwareMixin(object):
+ def on_root(self, wx, root):
+ session = root.session
+ # storage has been changed as a whole
+ session.bind(storage=self.on_storage)
+ # some parts of the storage have changed
+ session.bind(on_storage_modified=self.on_storage_modified)
+ session.bind(on_predicate_modified=self.on_predicate_modified)
+ if session.storage is not None:
+ # initialize with the current storage
+ # Going through the event dispatcher ensures that the object
+ # is initialized properly before on_storage is called.
+ Clock.schedule_once(lambda dt: self.on_storage(session, session.storage))
+
+ def on_storage(self, sender, storage):
+ """Default event handler."""
+ pass
+
+ def on_storage_modified(self, sender):
+ """Default event handler."""
+ pass
+
+ def on_predicate_modified(self, sender, predicate, objects, diff):
+ """Default event handler."""
+ pass
+
+class ConfigAwareMixin(object):
+ def on_root(self, wx, root):
+ session = root.session
+ # config changes as a whole
+ session.bind(cfg=self.on_cfg)
+ # individual config entries have been changed
+ session.bind(on_config_changed=self.on_config_changed)
+ if session.cfg is not None:
+ # initialize with the current config
+ # Going through the event dispatcher ensures that the object
+ # is initialized properly before on_cfg is called.
+ Clock.schedule_once(lambda dt: self.on_cfg(session, session.cfg))
+
+ def on_config_changed(self, sender, key, value):
+ """Default event handler."""
+ pass
+
+ def on_cfg(self, sender, cfg):
+ """Default event handler."""
+ pass
+
+## EOF ##
diff --git a/tagit/widgets/status.kv b/tagit/widgets/status.kv
new file mode 100644
index 0000000..0a680ab
--- /dev/null
+++ b/tagit/widgets/status.kv
@@ -0,0 +1,63 @@
+#-- #:import ButtonDock tagit.widgets.dock.ButtonDock # FIXME: mb/port
+
+<NavigationLabel@Label>:
+ markup: True
+
+<StatusLabel@Label>:
+ markup: True
+ valign: 'middle'
+ halign: 'center'
+
+<Status>:
+ orientation: 'horizontal'
+ status: ''
+ navigation: ''
+ status_label: status_label
+ navigation_label: navigation_label
+
+ ButtonDock:
+ root: root.root
+ orientation: 'lr-tb'
+ size_hint: None, 1
+ name: 'navigation_left'
+ # space for three buttons
+ width: 3*30 + 2*5
+ spacing: 5
+ button_height: 30
+ button_show: 'image',
+
+ NavigationLabel:
+ id: navigation_label
+ size_hint: None, 1
+ width: 180
+ text: root.navigation
+
+ ButtonDock:
+ root: root.root
+ size_hint: None, 1
+ orientation: 'lr-tb'
+ name: 'navigation_right'
+ # space for three buttons
+ width: 3*30 + 2*5
+ spacing: 5
+ button_height: 30
+ button_show: 'image',
+
+ StatusLabel:
+ # gets remaining size
+ id: status_label
+ text_size: self.size
+ text: root.status
+
+ ButtonDock:
+ root: root.root
+ orientation: 'lr-tb'
+ size_hint: None, 1
+ name: 'status'
+ # space for three buttons
+ width: 3*30 + 2*5
+ spacing: 5
+ button_height: 30
+ button_show: 'image',
+
+## EOF ##
diff --git a/tagit/widgets/status.py b/tagit/widgets/status.py
new file mode 100644
index 0000000..e83b8d8
--- /dev/null
+++ b/tagit/widgets/status.py
@@ -0,0 +1,206 @@
+"""Status line.
+
+Provides space for some buttons (typically navigation buttons),
+information about the current viewport, and a status line.
+
+Part of the tagit module.
+A copy of the license is provided with the project.
+Author: Matthias Baumgartner, 2022
+"""
+# standard imports
+import os
+import logging
+
+# kivy imports
+from kivy.clock import mainthread
+from kivy.lang import Builder
+from kivy.uix.boxlayout import BoxLayout
+import kivy.properties as kp
+
+# tagit imports
+from tagit import config, dialogues, logger
+
+# inner-module imports
+from .browser import BrowserAwareMixin
+from .session import ConfigAwareMixin
+
+# exports
+__all__ = ('Status', )
+
+
+## code ##
+
+# load kv
+Builder.load_file(os.path.join(os.path.dirname(__file__), 'status.kv'))
+
+# classes
+class Status(BoxLayout, BrowserAwareMixin, ConfigAwareMixin):
+ """Status line."""
+ # root reference
+ root = kp.ObjectProperty(None)
+ # log history
+ history = kp.ListProperty()
+ # log handlers
+ handler_history = None
+ handler_status = None
+
+ # events
+
+ __events__ = ('on_status', )
+
+ def on_status(sender, status):
+ """Event prototype"""
+ pass
+
+
+ # bindings to others
+
+ def on_root(self, wx, root):
+ """Bind events."""
+ # bind to browser and config
+ BrowserAwareMixin.on_root(self, wx, root)
+ ConfigAwareMixin.on_root(self, wx, root)
+ # bind to status update event
+ self.bind(on_status=self.status_from_event)
+
+ def on_browser(self, wx, browser):
+ """Bind to current browser properties."""
+ # remove old binding
+ if self.browser is not None:
+ self.browser.unbind(page_size=self.on_navigation)
+ self.browser.unbind(items=self.on_navigation)
+ self.browser.unbind(offset=self.on_navigation)
+ # add new binding
+ self.browser = browser
+ if self.browser is not None:
+ self.browser.bind(page_size=self.on_navigation)
+ self.browser.bind(items=self.on_navigation)
+ self.browser.bind(offset=self.on_navigation)
+ self.on_navigation(browser, browser.offset)
+
+ def on_config_changed(self, session, key, value):
+ if key in (('ui', 'standalone', 'logging', 'status'),
+ ('ui', 'standalone', 'logging', 'console')):
+ self.on_cfg(session, session.cfg)
+
+ def on_cfg(self, wx, cfg):
+ """Register handlers according to config."""
+ if self.handler_status is not None:
+ logging.getLogger().root.removeHandler(self.handler_status)
+ if self.handler_history is not None:
+ logging.getLogger().root.removeHandler(self.handler_history)
+
+ # status log event
+ self.handler_status = logger.logger_config(
+ logger.CallbackHandler(self.status_from_log),
+ logger.ColorsMarkup,
+ cfg('ui', 'standalone', 'logging', 'status').to_tree(defaults=True))
+ logging.getLogger().root.addHandler(self.handler_status)
+
+ # history (console)
+ self.handler_history = logger.logger_config(
+ logger.CallbackHandler(self.update_history),
+ logger.ColorsMarkup,
+ cfg('ui', 'standalone', 'logging', 'console').to_tree(defaults=True))
+ logging.getLogger().root.addHandler(self.handler_history)
+
+ def __del__(self):
+ if self.browser is not None:
+ self.browser.unbind(page_size=self.on_navigation)
+ self.browser.unbind(items=self.on_navigation)
+ self.browser.unbind(offset=self.on_navigation)
+ self.browser = None
+
+ if self.handler_status is not None:
+ logging.getLogger().root.removeHandler(self.handler_status)
+ self.handler_status = None
+
+ if self.handler_history is not None:
+ logging.getLogger().root.removeHandler(self.handler_history)
+ self.handler_history = None
+
+
+ # console
+
+ def on_touch_down(self, touch):
+ """Open console dialogue when clicked on the status label."""
+ if self.status_label.collide_point(*touch.pos):
+ self.console() # show console
+ return True
+ elif self.navigation_label.collide_point(*touch.pos):
+ self.root.trigger('JumpToPage') # show page dialogue
+ return True
+ return super(Status, self).on_touch_down(touch)
+
+ def console(self):
+ """Open console dialogue."""
+ dlg = dialogues.Console()
+ self.bind(history=dlg.update)
+ dlg.update(self, self.history)
+ dlg.open()
+
+
+ # content updates
+
+ def on_navigation(self, browser, value):
+ """Update the navigation label if the browser changes."""
+ first = browser.offset + 1 # first on page
+ last = min(browser.offset + browser.page_size, browser.n_items) # last on page
+ total = browser.n_items # total results
+ self.navigation = f'{first} - {last} of {total}'
+
+ @mainthread
+ def update_history(self, fmt, record):
+ """Update the history from the logger."""
+ self.history.append(fmt(record))
+
+ def status_from_event(self, wx, status):
+ """Update the status line from the status event."""
+ self.status = status
+
+ @mainthread
+ def status_from_log(self, fmt, record):
+ """Update the status line from the logger."""
+ self.status = fmt(record)
+
+
+## config ##
+
+# status
+config.declare(('ui', 'standalone', 'logging', 'status', 'level'),
+ config.Enum('debug', 'info', 'warn', 'error', 'critical'), 'info',
+ __name__, 'Log level', 'Maximal log level for which messages are shown. The order is: critical > error > warning > info > volatile > debug')
+
+config.declare(('ui', 'standalone', 'logging', 'status', 'filter'),
+ config.List(config.String()), ['tagit'],
+ __name__, 'Module filter', 'Module name for which log messages are accepted.')
+
+config.declare(('ui', 'standalone', 'logging', 'status', 'fmt'), config.String(), '{title}{message}',
+ __name__, 'Log format', 'Log message formatting. The formatting follows the typical python format string where each item is enclosed in curly braces (e.g. "{message}"). For printable items see the `logging attributes <https://docs.python.org/3.6/library/logging.html#logrecord-attributes>`_. In addition to those, the item "title" is available, which is a placeholder for the formatted title (if present).')
+
+config.declare(('ui', 'standalone', 'logging', 'status', 'title'), config.String(), '{title}: ',
+ __name__, 'Title format', 'Title formatting.')
+
+config.declare(('ui', 'standalone', 'logging', 'status', 'maxlen'), config.Unsigned(), 40,
+ __name__, 'Maximal line length', 'Maximal line length (e.g. console width). Use Infinity to set no line length limit.')
+
+# console
+config.declare(('ui', 'standalone', 'logging', 'console', 'level'),
+ config.Enum('debug', 'info', 'warn', 'error', 'critical'), 'info',
+ __name__, 'Log level', 'Maximal log level for which messages are shown. The order is: critical > error > warning > info > volatile > debug')
+
+config.declare(('ui', 'standalone', 'logging', 'console', 'filter'),
+ config.List(config.String()), ['tagit'],
+ __name__, 'Module filter', 'Module name for which log messages are accepted.')
+
+config.declare(('ui', 'standalone', 'logging', 'console', 'fmt'),
+ config.String(), '[{levelname}] {title}{message}',
+ __name__, 'Log format', 'Log message formatting. The formatting follows the typical python format string where each item is enclosed in curly braces (e.g. "{message}"). For printable items see the `logging attributes <https://docs.python.org/3.6/library/logging.html#logrecord-attributes>`_. In addition to those, the item "title" is available, which is a placeholder for the formatted title (if present).')
+
+config.declare(('ui', 'standalone', 'logging', 'console', 'title'), config.String(), '[{title}]',
+ __name__, 'Title format', 'Title formatting.')
+
+config.declare(('ui', 'standalone', 'logging', 'console', 'maxlen'), config.Unsigned(), 0,
+ __name__, 'Maximal line length', 'Maximal line length (e.g. console width). Use zero or infinity to set no line length limit.')
+
+## EOF ##