aboutsummaryrefslogtreecommitdiffstats
path: root/tagit/actions
diff options
context:
space:
mode:
Diffstat (limited to 'tagit/actions')
-rw-r--r--tagit/actions/__init__.py105
-rw-r--r--tagit/actions/action.kv30
-rw-r--r--tagit/actions/action.py257
-rw-r--r--tagit/actions/browser.kv99
-rw-r--r--tagit/actions/browser.py628
-rw-r--r--tagit/actions/filter.kv41
-rw-r--r--tagit/actions/filter.py314
-rw-r--r--tagit/actions/grouping.kv27
-rw-r--r--tagit/actions/grouping.py263
-rw-r--r--tagit/actions/misc.kv35
-rw-r--r--tagit/actions/misc.py178
-rw-r--r--tagit/actions/search.kv25
-rw-r--r--tagit/actions/search.py335
-rw-r--r--tagit/actions/session.kv7
-rw-r--r--tagit/actions/session.py56
-rw-r--r--tagit/actions/tagging.kv11
-rw-r--r--tagit/actions/tagging.py167
17 files changed, 2578 insertions, 0 deletions
diff --git a/tagit/actions/__init__.py b/tagit/actions/__init__.py
new file mode 100644
index 0000000..b8b65b8
--- /dev/null
+++ b/tagit/actions/__init__.py
@@ -0,0 +1,105 @@
+"""
+
+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.builder import BuilderBase
+
+# inner-module imports
+from . import browser
+from . import filter
+from . import grouping
+from . import misc
+#from . import objects
+from . import search
+from . import session
+from . import tagging
+
+# exports
+__all__: typing.Sequence[str] = (
+ 'ActionBuilder',
+ )
+
+
+## code ##
+
+class ActionBuilder(BuilderBase):
+ _factories = {
+ ## browser
+ 'NextPage': browser.NextPage,
+ 'PreviousPage': browser.PreviousPage,
+ 'ScrollDown': browser.ScrollDown,
+ 'ScrollUp': browser.ScrollUp,
+ 'JumpToPage': browser.JumpToPage,
+ 'SetCursor': browser.SetCursor,
+ 'ZoomIn': browser.ZoomIn,
+ 'ZoomOut': browser.ZoomOut,
+ 'JumpToCursor': browser.JumpToCursor,
+ 'MoveCursorFirst': browser.MoveCursorFirst,
+ 'MoveCursorLast': browser.MoveCursorLast,
+ 'MoveCursorUp': browser.MoveCursorUp,
+ 'MoveCursorDown': browser.MoveCursorDown,
+ 'MoveCursorLeft': browser.MoveCursorLeft,
+ 'MoveCursorRight': browser.MoveCursorRight,
+ 'SelectRange': browser.SelectRange,
+ 'SelectAdditive': browser.SelectAdditive,
+ 'SelectSubtractive': browser.SelectSubtractive,
+ 'SelectMulti': browser.SelectMulti,
+ 'SelectSingle': browser.SelectSingle,
+ 'SelectAll': browser.SelectAll,
+ 'SelectNone': browser.SelectNone,
+ 'SelectInvert': browser.SelectInvert,
+ 'Select': browser.Select,
+ ## filter
+ 'AddToken': filter.AddToken,
+ 'RemoveToken': filter.RemoveToken,
+ 'SetToken': filter.SetToken,
+ 'EditToken': filter.EditToken,
+ 'GoBack': filter.GoBack,
+ 'GoForth': filter.GoForth,
+ 'JumpToToken': filter.JumpToToken,
+ 'SearchByAddressOnce': filter.SearchByAddressOnce,
+ 'SearchmodeSwitch': filter.SearchmodeSwitch,
+ ## grouping
+ 'CreateGroup': grouping.CreateGroup,
+ 'DissolveGroup': grouping.DissolveGroup,
+ 'AddToGroup': grouping.AddToGroup,
+ 'OpenGroup': grouping.OpenGroup,
+ #'RepresentGroup': grouping.RepresentGroup,
+ #'RemoveFromGroup': grouping.RemoveFromGroup,
+ ## misc
+ 'ShellDrop': misc.ShellDrop,
+ 'OpenExternal': misc.OpenExternal,
+ 'Menu': misc.Menu,
+ 'ShowConsole': misc.ShowConsole,
+ 'ShowHelp': misc.ShowHelp,
+ 'ShowSettings': misc.ShowSettings,
+ #'ClipboardCopy': misc.ClipboardCopy,
+ #'ClipboardPaste': misc.ClipboardPaste,
+ ## objects
+ #'RotateLeft': objects.RotateLeft,
+ #'RotateRight': objects.RotateRight,
+ #'DeleteObject': objects.DeleteObject,
+ 'AddTag': tagging.AddTag,
+ 'EditTag': tagging.EditTag,
+ #'SetRank1': objects.SetRank1,
+ #'SetRank2': objects.SetRank2,
+ #'SetRank3': objects.SetRank3,
+ #'SetRank4': objects.SetRank4,
+ #'SetRank5': objects.SetRank5,
+ ## search
+ 'Search': search.Search,
+ 'ShowSelected': search.ShowSelected,
+ 'RemoveSelected': search.RemoveSelected,
+ #'SortKey': search.SortKey,
+ 'SortOrder': search.SortOrder,
+ ## session
+ 'LoadSession': session.LoadSession,
+ }
+
+## EOF ##
diff --git a/tagit/actions/action.kv b/tagit/actions/action.kv
new file mode 100644
index 0000000..807dd18
--- /dev/null
+++ b/tagit/actions/action.kv
@@ -0,0 +1,30 @@
+
+<Action>:
+ # internas
+ orientation: 'horizontal'
+
+ # responsiveness
+ # *touch_trigger* is enabled automatically if an image or text is shown.
+ # If that is undesired, *touch_trigger* has to be disabled **after** the
+ # declaration of *show*.
+ key_trigger: True
+ touch_trigger: False
+
+ # size
+ # By default the width expands as necessary. To get a fixed width,
+ # set the width manually or via size_hint_x and set *autowidth* to False.
+ # If something is shown, *height* is automatically set to *default_height*
+ # unless specified otherwise in by the caller.
+ default_height: 30
+ size: 0, 0
+ size_hint: None, None
+ autowidth: True
+
+ # behaviour
+ # The default is that no buttons are shown and touch triggers are disabled.
+ # NOTE: Callers need to declare show **last** to ensure that all other
+ # properties are set. The only exception is *touch_trigger* which has
+ # to be disabled **after** show.
+ show: []
+
+ ## EOF ##
diff --git a/tagit/actions/action.py b/tagit/actions/action.py
new file mode 100644
index 0000000..e8866ce
--- /dev/null
+++ b/tagit/actions/action.py
@@ -0,0 +1,257 @@
+"""Button for proxy actions.
+
+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 import metrics
+from kivy.animation import Animation
+from kivy.lang import Builder
+from kivy.uix.behaviors import ButtonBehavior
+from kivy.uix.boxlayout import BoxLayout
+from kivy.uix.image import Image
+from kivy.uix.label import Label
+import kivy.properties as kp
+
+# tagit imports
+from tagit.external.tooltip import Tooltip
+
+# exports
+__all__ = ('Action', )
+
+
+## code ##
+
+logger = logging.getLogger(__name__)
+
+# load kv
+Builder.load_file(os.path.join(os.path.dirname(__file__), 'action.kv'))
+
+class Action(ButtonBehavior, BoxLayout, Tooltip):
+ """
+
+ An Action can be triggered in three ways:
+ * Touch event: Clicking or touching on the button
+ * Key event: Entering a keyboard shortcut
+ * Single-shot: Programmatically triggered once, without UI attachment
+
+ For the last, use the *single_shot* classmethod.
+
+ For the first two, declare the Action in a kv file or in code.
+ Note that the remarks below about kv do not apply if the object is
+ created in code.
+
+
+ When an Action is declared in kv, two restrictions apply (also see
+ examples below):
+ * To disable touch_trigger, it must be declared last
+ * show must be declared after all other properties
+
+ Enable key triggers, but hide the Action in the UI:
+ Action:
+ show: []
+
+ Action:
+ # alias for the one above
+
+ Show text, image, or both, with default height and the width
+ stretched as necessary:
+ Action:
+ show: 'text',
+
+ Action:
+ show: 'image',
+
+ Action:
+ show: 'text', 'image'
+
+ Action:
+ width: 200 # has no effect unless autowidth is False
+ show: 'image', 'text'
+
+ Make the Action larger:
+ Action:
+ # increased height. The image width scales accordingly
+ height: 80
+ show: 'image', 'text'
+
+ Action:
+ # scales to parent widget's width
+ autowidth: False
+ size_hint_x: 1
+ show: 'image', 'text'
+
+ Action:
+ # fixed width and height
+ width: 150
+ height: 80
+ autowidth: False
+ show: 'image', 'text' # must be declared **last**
+
+ Show the button but disable touch events:
+ Action:
+ height: 80
+ show: 'image', 'text'
+ touch_trigger: False # must be declared **after** show
+
+ Do the same in code:
+ >>> Action(
+ ... size=(130, 80),
+ ... autowidth=False,
+ ... show=('image', 'text'),
+ ... text='foobar',
+ ... touch_trigger=False)
+
+ """
+ # content
+ tooltip = kp.StringProperty()
+ source = kp.StringProperty()
+ text = kp.StringProperty()
+
+ # visibility flags
+ show = kp.ListProperty([])
+
+ # sizing
+ default_height = kp.NumericProperty(30)
+ autowidth = kp.BooleanProperty(True)
+
+ # responsiveness
+ key_trigger = kp.BooleanProperty(True)
+ touch_trigger = kp.BooleanProperty(True)
+
+ # required such that properties can be set via constructor
+ font_size = kp.StringProperty("15sp")
+ # FIXME: Check why I have to pass size instead of height/width
+ height = kp.NumericProperty(0)
+ width = kp.NumericProperty(0)
+
+ # internal properties
+ root = kp.ObjectProperty(None)
+ selected_alpha = kp.NumericProperty(0)
+ _image = kp.ObjectProperty(None)
+ _label = kp.ObjectProperty(None)
+
+ def __init__(self, **kwargs):
+ show = kwargs.pop('show', [])
+ touch_trigger = kwargs.pop('touch_trigger', None)
+ super(Action, self).__init__(**kwargs)
+ # delay such that on_show is executed once the other
+ # properties are set
+ self.show = show
+ if touch_trigger is not None:
+ self.touch_trigger = touch_trigger
+
+ ## display
+
+ def on_show(self, wx, show):
+ if self._image is not None:
+ self.remove_widget(self._image)
+ if self._label is not None:
+ self.remove_widget(self._label)
+
+ if 'image' in show:
+ self.height = self.default_height if self.height == 0 else self.height
+ self.touch_trigger = True
+ self._image = Image(
+ source=self.source,
+ # size
+ height=self.height,
+ width=self.height, # square image
+ size_hint=(None, None),
+ )
+ self.add_widget(self._image)
+ if self.autowidth:
+ self.width = self.height
+ if 'text' in show:
+ self.height = self.default_height if self.height == 0 else self.height
+ self.touch_trigger = True
+ self._label = Label(
+ # text
+ text=self.text,
+ halign='left',
+ valign='middle',
+ padding=(metrics.dp(10), 0),
+ # size
+ font_size=self.font_size,
+ height=self.height,
+ size_hint=(None, None),
+ )
+ self._label.bind(texture_size=self.on_texture_size)
+ self.add_widget(self._label)
+
+ def on_size(self, wx, size):
+ for child in self.children:
+ child.height = self.height
+ if isinstance(child, Image):
+ child.width = self.height
+ elif not self.autowidth: # must be the label
+ self.on_texture_size(child, None)
+
+ def on_texture_size(self, label, texture_size):
+ if self.autowidth:
+ # adapt the width to the label's width
+ self.width = max(0, sum([child.width for child in self.children]))
+ else:
+ # adapt the label's width
+ others = sum([child.width for child in self.children if child != label])
+ label.width = self.width - others
+ label.height = self.height
+ label.text_size = (self.width - others, self.height)
+
+ def on_tooltip(self, callee, text):
+ self.set_tooltip(text)
+
+
+ ## properties
+
+ @property
+ def cfg(self):
+ return self.root.session.cfg
+
+
+ ## action triggering
+
+ @classmethod
+ def single_shot(cls, root, *args, **kwargs):
+ #logger.info(f'action: {cls.__name__}, {args}, {kwargs}')
+ root.action_log.append(str(cls.__name__))
+ return cls(root=root).apply(*args, **kwargs)
+
+ def on_root(self, wx, root):
+ root.keys.bind(on_press=self.on_keyboard)
+
+ def on_press(self):
+ self.selected_alpha = 1
+
+ def on_release(self):
+ if self.touch_trigger:
+ Animation(selected_alpha=0, d=.25, t='out_quad').start(self)
+ #logger.info(f'action: {type(self).__name__}')
+ self.root.action_log.append(str(type(self).__name__))
+ self.apply()
+
+ def on_keyboard(self, wx, evt):
+ if self.key_trigger and self.ktrigger(evt):
+ #logger.info(f'action: {type(self).__name__}')
+ self.root.action_log.append(str(type(self).__name__))
+ self.apply()
+ # stop the event from further processing
+ return True
+
+
+ ## interfaces for subtypes
+
+ def ktrigger(self, evt):
+ """Return True if the action should be triggered by keyboard event *evt*."""
+ return False
+
+ def apply(self, *args, **kwargs):
+ """Execute the action."""
+ pass
+
+## EOF ##
diff --git a/tagit/actions/browser.kv b/tagit/actions/browser.kv
new file mode 100644
index 0000000..adcfbd6
--- /dev/null
+++ b/tagit/actions/browser.kv
@@ -0,0 +1,99 @@
+#:import resource_find kivy.resources.resource_find
+
+<NextPage>:
+ source: resource_find('atlas://browser/next_page')
+ tooltip: 'One page down'
+
+<PreviousPage>:
+ source: resource_find('atlas://browser/previous_page')
+ tooltip: 'One page up'
+
+<ScrollUp>:
+ source: resource_find('atlas://browser/scroll_up')
+ tooltip: 'One row up'
+
+<ScrollDown>:
+ source: resource_find('atlas://browser/scroll_down')
+ tooltip: 'One row down'
+
+<JumpToPage>:
+ source: resource_find('atlas://browser/jump_to_page')
+ tooltip: 'Jump to a specified page'
+
+<ZoomIn>:
+ source: resource_find('atlas://browser/zoom_in')
+ tooltip: 'Zoom in'
+
+<ZoomOut>:
+ source: resource_find('atlas://browser/zoom_out')
+ tooltip: 'Zoom out'
+
+<JumpToCursor>:
+ source: resource_find('atlas://browser/jump_to_cursor')
+ tooltip: 'Jump to cursor'
+
+<SetCursor>:
+ source: resource_find('atlas://browser/set_cursor')
+ tooltip: 'Set the cursor'
+
+<MoveCursorFirst>:
+ source: resource_find('atlas://browser/cursor_first')
+ tooltip: 'Go to first image'
+
+<MoveCursorLast>:
+ source: resource_find('atlas://browser/cursor_last')
+ tooltip: 'Go to last image'
+
+<MoveCursorUp>:
+ source: resource_find('atlas://browser/cursor_up')
+ tooltip: 'Cursor up'
+
+<MoveCursorDown>:
+ source: resource_find('atlas://browser/cursor_down')
+ tooltip: 'Cursor down'
+
+<MoveCursorLeft>:
+ source: resource_find('atlas://browser/cursor_left')
+ tooltip: 'Cursor left'
+
+<MoveCursorRight>:
+ source: resource_find('atlas://browser/cursor_right')
+ tooltip: 'Cursor right'
+
+<SelectAll>:
+ source: resource_find('atlas://browser/select_all')
+ tooltip: 'Select all'
+
+<SelectNone>:
+ source: resource_find('atlas://browser/select_none')
+ tooltip: 'Clear selection'
+
+<SelectInvert>:
+ source: resource_find('atlas://browser/select_invert')
+ tooltip: 'Invert selection'
+
+<SelectSingle>:
+ source: resource_find('atlas://browser/select_single')
+ tooltip: 'Select one'
+
+<SelectMulti>:
+ source: resource_find('atlas://browser/select_multi')
+ tooltip: 'Select many'
+
+<SelectAdditive>:
+ source: resource_find('atlas://browser/select_add')
+ tooltip: 'Add to selection'
+
+<SelectSubtractive>:
+ source: resource_find('atlas://browser/select_sub')
+ tooltip: 'Remove from selection'
+
+<SelectRange>:
+ source: resource_find('atlas://browser/select_range')
+ tooltip: 'Select range'
+
+<Select>:
+ source: resource_find('atlas://browser/select')
+ tooltip: 'Select/deselect an item'
+
+## EOF ##
diff --git a/tagit/actions/browser.py b/tagit/actions/browser.py
new file mode 100644
index 0000000..2eaead8
--- /dev/null
+++ b/tagit/actions/browser.py
@@ -0,0 +1,628 @@
+"""
+
+Part of the tagit module.
+A copy of the license is provided with the project.
+Author: Matthias Baumgartner, 2022
+"""
+# standard imports
+import math
+import os
+
+# kivy imports
+from kivy.lang import Builder
+import kivy.properties as kp
+
+# tagit imports
+from tagit import config, dialogues
+from tagit.utils import clamp
+from tagit.widgets import Binding
+
+# inner-module imports
+from .action import Action
+
+# exports
+__all__ = []
+
+
+## code ##
+
+# load kv
+Builder.load_file(os.path.join(os.path.dirname(__file__), 'browser.kv'))
+
+# classes
+
+class NextPage(Action):
+ """Scroll one page downwards without moving the cursor."""
+ text = kp.StringProperty('Next page')
+
+ def ktrigger(self, evt):
+ return Binding.check(evt, self.cfg('bindings', 'browser', 'page_next'))
+
+ def apply(self):
+ with self.root.browser as browser:
+ browser.offset = clamp(browser.offset + browser.page_size, browser.max_offset)
+
+
+class PreviousPage(Action):
+ """Scroll one page upwards without moving the cursor."""
+ text = kp.StringProperty('Previous page')
+ def ktrigger(self, evt):
+ return Binding.check(evt, self.cfg('bindings', 'browser', 'page_prev'))
+
+ def apply(self):
+ with self.root.browser as browser:
+ browser.offset = max(browser.offset - browser.page_size, 0)
+
+
+class ScrollUp(Action):
+ """Scroll one row up without moving the cursor."""
+ text = kp.StringProperty('Scroll up')
+
+ def ktrigger(self, evt):
+ return Binding.check(evt, self.cfg('bindings', 'browser', 'scroll_up'))
+
+ def on_touch_down(self, touch):
+ scrollcfg = self.cfg('ui', 'standalone', 'browser', 'scroll')
+ scrolldir = 'scrolldown' if scrollcfg == 'mouse' else 'scrollup'
+ if self.root.browser.collide_point(*touch.pos) \
+ and not self.root.keys.ctrl_pressed:
+ if touch.button == scrolldir:
+ self.apply()
+ return super(ScrollUp, self).on_touch_down(touch)
+
+ def apply(self):
+ with self.root.browser as browser:
+ browser.offset = clamp(browser.offset - browser.cols, browser.max_offset)
+
+
+class ScrollDown(Action):
+ """Scroll one row down without moving the cursor."""
+ text = kp.StringProperty('Scroll down')
+
+ def ktrigger(self, evt):
+ return Binding.check(evt, self.cfg('bindings', 'browser', 'scroll_down'))
+
+ def on_touch_down(self, touch):
+ scrollcfg = self.cfg('ui', 'standalone', 'browser', 'scroll')
+ scrolldir = 'scrollup' if scrollcfg == 'mouse' else 'scrolldown'
+ if self.root.browser.collide_point(*touch.pos) \
+ and not self.root.keys.ctrl_pressed:
+ if touch.button == scrolldir:
+ self.apply()
+ return super(ScrollDown, self).on_touch_down(touch)
+
+ def apply(self):
+ with self.root.browser as browser:
+ browser.offset = clamp(browser.offset + browser.cols, browser.max_offset)
+
+
+class JumpToPage(Action):
+ """Jump to a specified offset."""
+ text = kp.StringProperty('Go to page')
+
+ def apply(self, offset=None):
+ if offset is None:
+ browser = self.root.browser
+ dlg = dialogues.NumericInput(lo=0, hi=browser.max_offset, init_value=browser.offset)
+ dlg.bind(on_ok=lambda wx: self.set_offset(wx.value))
+ dlg.open()
+ else:
+ self.set_offset(offset)
+
+ def set_offset(self, offset):
+ with self.root.browser as browser:
+ browser.offset = clamp(offset, browser.max_offset)
+
+class ZoomIn(Action):
+ """Decrease the grid size."""
+ text = kp.StringProperty('Zoom in')
+
+ def ktrigger(self, evt):
+ return Binding.check(evt, self.cfg('bindings', 'browser', 'zoom_in'))
+
+ # TODO: zoom by gesture
+
+ def on_touch_down(self, touch): # not triggered (but ScrollDown is!)
+ if self.root.browser.collide_point(*touch.pos) \
+ and self.root.keys.ctrl_pressed:
+ if touch.button == 'scrolldown':
+ self.apply()
+ return super(ZoomIn, self).on_touch_down(touch)
+
+ def apply(self):
+ with self.root.browser as browser:
+ step = self.cfg('ui', 'standalone', 'browser', 'zoom_step')
+ if browser.gridmode == browser.GRIDMODE_LIST:
+ cols = browser.cols
+ else:
+ cols = max(1, browser.cols - step)
+ rows = max(1, browser.rows - step)
+ # TODO: Zoom to center? (adjust offset)
+ if cols != browser.cols or rows != browser.rows:
+ # clear widgets first, otherwise GridLayout will
+ # complain about too many childrens.
+ browser.clear_widgets()
+ # adjust the grid size
+ browser.cols = cols
+ browser.rows = rows
+
+
+class ZoomOut(Action):
+ """Increase the grid size."""
+ text = kp.StringProperty('Zoom out')
+
+ def ktrigger(self, evt):
+ return Binding.check(evt, self.cfg('bindings', 'browser', 'zoom_out'))
+
+ # TODO: zoom by gesture
+
+ def on_touch_down(self, touch):
+ if self.root.browser.collide_point(*touch.pos) \
+ and self.root.keys.ctrl_pressed:
+ if touch.button == 'scrollup':
+ self.apply()
+ return super(ZoomOut, self).on_touch_down(touch)
+
+ def apply(self):
+ with self.root.browser as browser:
+ # TODO: Zoom from center? (adjust offset)
+ step = self.cfg('ui', 'standalone', 'browser', 'zoom_step')
+ # get maxcols
+ maxcols = self.cfg('ui', 'standalone', 'browser', 'maxcols')
+ maxcols = float('inf') if maxcols <= 0 else maxcols
+ # get maxrows
+ maxrows = self.cfg('ui', 'standalone', 'browser', 'maxrows')
+ maxrows = float('inf') if maxrows <= 0 else maxrows
+ # set cols/rows
+ if browser.gridmode != browser.GRIDMODE_LIST:
+ browser.cols = min(browser.cols + step, maxcols)
+ browser.rows = min(browser.rows + step, maxrows)
+ # adjust offset to ensure that one full page is visible
+ browser.offset = clamp(browser.offset, browser.max_offset)
+
+
+class JumpToCursor(Action):
+ """Focus the field of view at the cursor."""
+ text = kp.StringProperty('Find cursor')
+
+ def apply(self):
+ with self.root.browser as browser:
+ if browser.cursor is None:
+ # cursor not set, nothing to do
+ pass
+ else:
+ idx = browser.items.index(browser.cursor)
+ if idx < browser.offset:
+ # cursor is above view, scroll up such that the cursor
+ # is in the first row.
+ offset = math.floor(idx / browser.cols) * browser.cols
+ browser.offset = clamp(offset, browser.max_offset)
+ elif browser.offset + browser.page_size <= idx:
+ # cursor is below view, scroll down such that the cursor
+ # is in the last row.
+ offset = math.floor(idx / browser.cols) * browser.cols
+ offset -= (browser.page_size - browser.cols)
+ browser.offset = clamp(offset, browser.max_offset)
+ else:
+ # cursor is visible, nothing to do
+ pass
+
+
+class SetCursor(Action):
+ """Set the cursor to a specific item."""
+ text = kp.StringProperty('Set cursor')
+
+ def apply(self, obj):
+ with self.root.browser as browser:
+ browser.cursor = obj
+ self.root.trigger('JumpToCursor')
+ # is invoked via mouse click only, hence
+ # the item selection should always toggle
+ self.root.trigger('Select', browser.cursor)
+
+
+class MoveCursorFirst(Action):
+ """Set the cursor to the first item."""
+ text = kp.StringProperty('First')
+
+ def ktrigger(self, evt):
+ return Binding.check(evt, self.cfg('bindings', 'browser', 'go_first'))
+
+ def apply(self):
+ with self.root.browser as browser:
+ if browser.n_items == 0:
+ # browser is empty, nothing to do
+ pass
+ else:
+ # set cursor to first item
+ old = browser.cursor
+ browser.cursor = browser.items[0]
+ # scroll to first page if need be
+ self.root.trigger('JumpToCursor')
+ # fix selection
+ if browser.select_mode != browser.SELECT_MULTI and \
+ (browser.select_mode != browser.SELECT_SINGLE or old != browser.cursor):
+ self.root.trigger('Select', browser.cursor)
+
+
+class MoveCursorLast(Action):
+ """Set the cursor to the last item."""
+ text = kp.StringProperty('Last')
+
+ def ktrigger(self, evt):
+ return Binding.check(evt, self.cfg('bindings', 'browser', 'go_last'))
+
+ def apply(self):
+ with self.root.browser as browser:
+ if browser.n_items == 0:
+ # browser is empty, nothing to do
+ pass
+ else:
+ # set cursor to last item
+ old = browser.cursor
+ browser.cursor = browser.items[-1]
+ # scroll to last page if need be
+ self.root.trigger('JumpToCursor')
+ # fix selection
+ if browser.select_mode != browser.SELECT_MULTI and \
+ (browser.select_mode != browser.SELECT_SINGLE or old != browser.cursor):
+ self.root.trigger('Select', browser.cursor)
+
+
+class MoveCursorUp(Action):
+ """Move the cursor one item upwards. Scroll if needbe."""
+ text = kp.StringProperty('Cursor up')
+
+ def ktrigger(self, evt):
+ return Binding.check(evt, self.cfg('bindings', 'browser', 'cursor_up'))
+
+ def apply(self):
+ with self.root.browser as browser:
+ if browser.n_items == 0:
+ # browser is empty, nothing to do
+ pass
+ elif browser.cursor is None:
+ # cursor wasn't set before. Set to last item
+ self.root.trigger('MoveCursorLast')
+ else:
+ # move cursor one row up
+ old = browser.items.index(browser.cursor)
+ # check if the cursor is in the first row already
+ if old < browser.cols: return # first row already
+ # move cursor up
+ new = clamp(old - browser.cols, browser.n_items - 1)
+ browser.cursor = browser.items[new]
+ # fix field of view
+ self.root.trigger('JumpToCursor')
+ # fix selection
+ if browser.select_mode != browser.SELECT_MULTI and \
+ (browser.select_mode != browser.SELECT_SINGLE or old != new):
+ self.root.trigger('Select', browser.cursor)
+
+
+class MoveCursorDown(Action):
+ """Move the cursor one item downwards. Scroll if needbe."""
+ text = kp.StringProperty('Cursor down')
+
+ def ktrigger(self, evt):
+ return Binding.check(evt, self.cfg('bindings', 'browser', 'cursor_down'))
+
+ def apply(self):
+ with self.root.browser as browser:
+ if browser.n_items == 0:
+ # browser is empty, nothing to do
+ pass
+ elif browser.cursor is None:
+ # cursor wasn't set before. Set to first item
+ self.root.trigger('MoveCursorFirst')
+ else:
+ # move cursor one row down
+ old = browser.items.index(browser.cursor)
+ # check if the cursor is in the last row already
+ last_row = browser.n_items % browser.cols
+ last_row = last_row if last_row > 0 else browser.cols
+ if old >= browser.n_items - last_row: return # last row already
+ # move cursor down
+ new = clamp(old + browser.cols, browser.n_items - 1)
+ browser.cursor = browser.items[new]
+ # fix field of view
+ self.root.trigger('JumpToCursor')
+ # fix selection
+ if browser.select_mode != browser.SELECT_MULTI and \
+ (browser.select_mode != browser.SELECT_SINGLE or old != new):
+ self.root.trigger('Select', browser.cursor)
+
+
+class MoveCursorLeft(Action):
+ """Move the cursor to the previous item."""
+ text = kp.StringProperty('Cursor left')
+
+ def ktrigger(self, evt):
+ return Binding.check(evt, self.cfg('bindings', 'browser', 'cursor_left'))
+
+ def apply(self):
+ with self.root.browser as browser:
+ if browser.n_items == 0:
+ # browser is empty, nothing to do
+ pass
+ elif browser.cursor is None:
+ # cursor wasn't set before. Set to the last item
+ self.root.trigger('MoveCursorLast')
+ else:
+ # move cursor one position to the left
+ old = browser.items.index(browser.cursor)
+ new = clamp(old - 1, browser.n_items - 1)
+ browser.cursor = browser.items[new]
+ self.root.trigger('JumpToCursor')
+ # fix selection
+ if browser.select_mode != browser.SELECT_MULTI and \
+ (browser.select_mode != browser.SELECT_SINGLE or old != new):
+ self.root.trigger('Select', browser.cursor)
+
+
+class MoveCursorRight(Action):
+ """Move the cursor to the next item."""
+ text = kp.StringProperty('Cursor right')
+
+ def ktrigger(self, evt):
+ return Binding.check(evt, self.cfg('bindings', 'browser', 'cursor_right'))
+
+ def apply(self):
+ with self.root.browser as browser:
+ if browser.n_items == 0:
+ # browser is empty, nothing to do
+ pass
+ elif browser.cursor is None:
+ # cursor wasn't set before. Set to the last item
+ self.root.trigger('MoveCursorFirst')
+ else:
+ # move cursor one position to the right
+ old = browser.items.index(browser.cursor)
+ new = clamp(old + 1, browser.n_items - 1)
+ browser.cursor = browser.items[new]
+ self.root.trigger('JumpToCursor')
+ # fix selection
+ if browser.select_mode != browser.SELECT_MULTI and \
+ (browser.select_mode != browser.SELECT_SINGLE or old != new):
+ self.root.trigger('Select', browser.cursor)
+
+
+class SelectAll(Action):
+ """Select all items."""
+ text = kp.StringProperty('Select all')
+
+ def ktrigger(self, evt):
+ return Binding.check(evt, self.cfg('bindings', 'browser', 'select_all'))
+
+ def apply(self):
+ with self.root.browser as browser:
+ browser.selection = browser.items.as_set().copy()
+
+
+class SelectNone(Action):
+ """Clear the selection."""
+ text = kp.StringProperty('Clear selection')
+
+ def ktrigger(self, evt):
+ return Binding.check(evt, self.cfg('bindings', 'browser', 'select_none'))
+
+ def apply(self):
+ with self.root.browser as browser:
+ browser.selection = set()
+
+
+class SelectInvert(Action):
+ """Invert the selection."""
+ text = kp.StringProperty('Invert selection')
+
+ def apply(self):
+ with self.root.browser as browser:
+ browser.selection = browser.items.as_set() - browser.selection
+
+
+class SelectSingle(Action):
+ """Select only the cursor."""
+ text = kp.StringProperty('Select one')
+
+ def apply(self):
+ with self.root.browser as browser:
+ browser.select_mode = browser.SELECT_SINGLE
+
+
+class SelectAdditive(Action):
+ """Set the selection mode to additive select."""
+ text = kp.StringProperty('Always select')
+
+ def apply(self):
+ with self.root.browser as browser:
+ browser.select_mode = browser.SELECT_ADDITIVE
+
+
+class SelectSubtractive(Action):
+ """Set the selection mode to subtractive select."""
+ text = kp.StringProperty('Always deselect')
+
+ def apply(self):
+ with self.root.browser as browser:
+ browser.select_mode = browser.SELECT_SUBTRACTIVE
+
+
+class SelectMulti(Action):
+ """Set the selection mode to random access."""
+ text = kp.StringProperty('Select many')
+ browser = kp.ObjectProperty(None, allownone=True)
+
+ def ktrigger(self, evt):
+ key, _, _ = evt
+ if key in self.root.keys.modemap[self.root.keys.MODIFIERS_CTRL]:
+ self.browser = self.root.browser
+ self.apply()
+
+ def on_root(self, wx, root):
+ super(SelectMulti, self).on_root(wx, root)
+ root.keys.bind(on_release=self.on_key_up)
+
+ def on_key_up(self, wx, key):
+ if key in self.root.keys.modemap[self.root.keys.MODIFIERS_CTRL]:
+ if self.browser is not None:
+ with self.browser as browser:
+ if browser.select_mode & browser.SELECT_MULTI:
+ browser.select_mode -= browser.SELECT_MULTI
+
+ def apply(self):
+ with self.root.browser as browser:
+ browser.select_mode |= browser.SELECT_MULTI
+
+
+class SelectRange(Action):
+ """Set the selection mode to range select."""
+ text = kp.StringProperty('Select range')
+ browser = kp.ObjectProperty(None, allownone=True)
+
+ def ktrigger(self, evt):
+ key, _, _ = evt
+ if key in self.root.keys.modemap[self.root.keys.MODIFIERS_SHIFT]:
+ self.browser = self.root.browser
+ self.apply()
+
+ def on_root(self, wx, root):
+ super(SelectRange, self).on_root(wx, root)
+ root.keys.bind(on_release=self.on_key_up)
+
+ def on_key_up(self, wx, key):
+ if key in self.root.keys.modemap[self.root.keys.MODIFIERS_SHIFT]:
+ if self.browser is not None:
+ with self.browser as browser:
+ if browser.select_mode & browser.SELECT_RANGE:
+ browser.select_mode -= browser.SELECT_RANGE
+ browser.range_base = set()
+ browser.range_origin = None
+
+ def apply(self):
+ with self.root.browser as browser:
+ browser.select_mode |= browser.SELECT_RANGE
+ browser.range_base = browser.selection.copy()
+ idx = None if browser.cursor is None else browser.items.index(browser.cursor)
+ browser.range_origin = idx
+
+
+class Select(Action):
+ """Select or deselect an item. How the selection changes depends on the selection mode."""
+ text = kp.StringProperty('Select')
+
+ def ktrigger(self, evt):
+ return Binding.check(evt, self.cfg('bindings', 'browser', 'select'))
+
+ def apply(self, obj=None):
+ with self.root.browser as browser:
+ obj = obj if obj is not None else browser.cursor
+
+ if obj is None:
+ # nothing to do
+ pass
+
+ elif browser.select_mode & browser.SELECT_ADDITIVE:
+ browser.selection.add(obj)
+
+ elif browser.select_mode & browser.SELECT_SUBTRACTIVE:
+ if obj in browser.selection:
+ browser.selection.remove(obj)
+
+ elif browser.select_mode & browser.SELECT_RANGE:
+ idx = browser.items.index(obj)
+ lo = min(idx, browser.range_origin)
+ hi = max(idx, browser.range_origin)
+ browser.selection = browser.range_base | set(browser.items[lo:hi+1])
+
+ elif browser.select_mode & browser.SELECT_MULTI:
+ # Toggle
+ if obj in browser.selection:
+ browser.selection.remove(obj)
+ else:
+ browser.selection.add(obj)
+
+ elif browser.select_mode == 0: #elif browser.select_mode & browser.SELECT_SINGLE:
+ # Toggle
+ if obj in browser.selection:
+ browser.selection = set()
+ else:
+ browser.selection = {obj}
+
+
+## config ##
+
+config.declare(('ui', 'standalone', 'browser', 'maxcols'), config.Unsigned(), 0,
+ __name__, 'Column maximum', 'Maximal number of columns. This guards against aggressive zooming, because the application may become unresponsive if too many preview images are shown on the same page. A value of Infinity means that no limit is enforced.')
+
+config.declare(('ui', 'standalone', 'browser', 'maxrows'), config.Unsigned(), 0,
+ __name__, 'Row maximum', 'Maximal number of rows. This guards against aggressive zooming, because the application may become unresponsive if too many preview images are shown on the same page. A value of Infinity means that no limit is enforced.')
+
+config.declare(('ui', 'standalone', 'browser', 'zoom_step'), config.Unsigned(), 1,
+ __name__, 'Zoom step', 'Controls by how much the gridsize is increased or decreased when zoomed in or out. Affects both dimensions (cols/rows) in grid mode.')
+
+config.declare(('ui', 'standalone', 'browser', 'scroll'), config.Enum('mouse', 'touch'), 'mouse',
+ __name__, 'Inverted scroll', 'To scroll downwards, one can either move the fingers in an upward direction (touch) or use the scroll wheel in a downward direction (mouse).')
+
+# keybindings
+
+config.declare(('bindings', 'browser', 'page_next'),
+ config.Keybind(), Binding.simple(Binding.PGDN),
+ __name__, NextPage.text.defaultvalue, NextPage.__doc__)
+
+config.declare(('bindings', 'browser', 'page_prev'),
+ config.Keybind(), Binding.simple(Binding.PGUP),
+ __name__, PreviousPage.text.defaultvalue, PreviousPage.__doc__)
+
+config.declare(('bindings', 'browser', 'scroll_up'),
+ config.Keybind(), Binding.simple('k', None, Binding.mALL),
+ __name__, ScrollUp.text.defaultvalue, ScrollUp.__doc__)
+
+config.declare(('bindings', 'browser', 'scroll_down'),
+ config.Keybind(), Binding.simple('j', None, Binding.mALL),
+ __name__, ScrollDown.text.defaultvalue, ScrollDown.__doc__)
+
+config.declare(('bindings', 'browser', 'zoom_in'),
+ config.Keybind(), Binding.simple('+'),
+ __name__, ZoomIn.text.defaultvalue, ZoomIn.__doc__)
+
+config.declare(('bindings', 'browser', 'zoom_out'),
+ config.Keybind(), Binding.simple('-'),
+ __name__, ZoomOut.text.defaultvalue, ZoomOut.__doc__)
+
+config.declare(('bindings', 'browser', 'go_first'),
+ config.Keybind(), Binding.simple(Binding.HOME),
+ __name__, MoveCursorFirst.text.defaultvalue, MoveCursorFirst.__doc__)
+
+config.declare(('bindings', 'browser', 'go_last'),
+ config.Keybind(), Binding.simple(Binding.END),
+ __name__, MoveCursorLast.text.defaultvalue, MoveCursorLast.__doc__)
+
+config.declare(('bindings', 'browser', 'cursor_up'),
+ config.Keybind(), Binding.simple(Binding.UP),
+ __name__, MoveCursorUp.text.defaultvalue, MoveCursorUp.__doc__)
+
+config.declare(('bindings', 'browser', 'cursor_down'),
+ config.Keybind(), Binding.simple(Binding.DOWN),
+ __name__, MoveCursorDown.text.defaultvalue, MoveCursorDown.__doc__)
+
+config.declare(('bindings', 'browser', 'cursor_left'),
+ config.Keybind(), Binding.simple(Binding.LEFT),
+ __name__, MoveCursorLeft.text.defaultvalue, MoveCursorLeft.__doc__)
+
+config.declare(('bindings', 'browser', 'cursor_right'),
+ config.Keybind(), Binding.simple(Binding.RIGHT),
+ __name__, MoveCursorRight.text.defaultvalue, MoveCursorRight.__doc__)
+
+config.declare(('bindings', 'browser', 'select_all'),
+ config.Keybind(), Binding.simple('a', Binding.mCTRL, Binding.mREST),
+ __name__, SelectAll.text.defaultvalue, SelectAll.__doc__)
+
+config.declare(('bindings', 'browser', 'select_none'),
+ config.Keybind(), Binding.simple('a', (Binding.mCTRL, Binding.mSHIFT), Binding.mREST),
+ __name__, SelectNone.text.defaultvalue, SelectNone.__doc__)
+
+config.declare(('bindings', 'browser', 'select'),
+ config.Keybind(), Binding.simple(Binding.SPACEBAR),
+ __name__, Select.text.defaultvalue, Select.__doc__)
+
+## EOF ##
diff --git a/tagit/actions/filter.kv b/tagit/actions/filter.kv
new file mode 100644
index 0000000..2fce0e2
--- /dev/null
+++ b/tagit/actions/filter.kv
@@ -0,0 +1,41 @@
+#:import resource_find kivy.resources.resource_find
+
+<SearchByAddressOnce>:
+ source: resource_find('atlas://filter/address_once')
+ tooltip: 'Open the filters in address mode for a single edit'
+
+<SetToken>:
+ source: resource_find('atlas://filter/set_token')
+ tooltip: 'Set all filters from a text query'
+
+<AddToken>:
+ source: resource_find('atlas://filter/add')
+ tooltip: 'Add a tag filter'
+
+<EditToken>:
+ source: resource_find('atlas://filter/edit_token')
+ tooltip: 'Edit a filter token'
+
+<RemoveToken>:
+ source: resource_find('atlas://filter/remove_token')
+ tooltip: 'Remove a filter token'
+
+<GoBack>:
+ source: resource_find('atlas://filter/go_back')
+ tooltip: 'Previous view'
+
+<GoForth>:
+ source: resource_find('atlas://filter/go_forth')
+ tooltip: 'Next view'
+
+<JumpToToken>:
+ source: resource_find('atlas://filter/jump')
+ tooltip: 'Jump to filter token'
+
+<SearchmodeSwitch>:
+ source_address: resource_find('atlas://filter/address')
+ source_shingles: resource_find('atlas://filter/shingles')
+ source: self.source_shingles
+ tooltip: 'Switch between adress and shingles display'
+
+## EOF ##
diff --git a/tagit/actions/filter.py b/tagit/actions/filter.py
new file mode 100644
index 0000000..52f5817
--- /dev/null
+++ b/tagit/actions/filter.py
@@ -0,0 +1,314 @@
+"""
+
+Part of the tagit module.
+A copy of the license is provided with the project.
+Author: Matthias Baumgartner, 2022
+"""
+# standard imports
+import os
+
+# kivy imports
+from kivy.lang import Builder
+import kivy.properties as kp
+
+# tagit imports
+from tagit import config, dialogues
+from tagit.utils import errors, ns, Frame
+from tagit.utils.bsfs import ast
+from tagit.widgets import Binding
+from tagit.widgets.filter import FilterAwareMixin
+
+# inner-module imports
+from .action import Action
+
+# exports
+__all__ = []
+
+
+## code ##
+
+# load kv
+Builder.load_file(os.path.join(os.path.dirname(__file__), 'filter.kv'))
+
+# classes
+
+class SearchByAddressOnce(Action):
+ """Open the filters in address mode for a single edit"""
+ text = kp.StringProperty('Inline edit')
+
+ def ktrigger(self, evt):
+ return Binding.check(evt, self.cfg('bindings', 'filter', 'edit_once'))
+
+ def apply(self):
+ self.root.filter.show_address_once()
+
+
+class SetToken(Action):
+ """Set all filters from a text query."""
+ text = kp.StringProperty('Set tokens')
+
+ def apply(self, text):
+ with self.root.filter as filter:
+ try:
+ # parse filter into tokens
+ tokens = list(self.root.session.filter_from_string(text))
+
+ # grab current frame
+ filter.f_head.append(self.root.browser.frame)
+
+ # keep frames for tokens that didn't change
+ # create a new frame for changed (new or modified) tokens
+ frames = [filter.f_head[0]]
+ for tok in tokens:
+ if tok in filter.t_head: # t_head frames have precedence
+ frame = filter.f_head[filter.t_head.index(tok) + 1]
+ elif tok in filter.t_tail:
+ frame = filter.f_tail[filter.t_tail.index(tok)]
+ else:
+ frame = Frame()
+ frames.append(frame)
+ # use the last frame as current one
+ self.root.browser.frame = frames.pop()
+
+ # set tokens
+ filter.t_head = tokens
+ filter.t_tail = []
+ # set frames
+ filter.f_head = frames
+ filter.f_tail = []
+
+ except ParserError as e:
+ dialogues.Error(text=f'syntax error: {e}').open()
+
+
+class AddToken(Action):
+ """Show a dialogue for adding a filter."""
+ text = kp.StringProperty('Add filter')
+
+ def ktrigger(self, evt):
+ return Binding.check(evt, self.cfg('bindings', 'filter', 'add_token'))
+
+ def apply(self, token=None):
+ if token is None:
+ sugg = self.root.session.storage.all(ns.bsn.Tag).label(node=False)
+ dlg = dialogues.TokenEdit(suggestions=sugg)
+ dlg.bind(on_ok=lambda wx: self.add_from_string(wx.text))
+ dlg.open()
+ elif isinstance(token, str):
+ self.add_from_string(token)
+ elif isinstance(token, ast.filter.FilterExpression):
+ self.add_token([token])
+
+ def add_from_string(self, text):
+ try:
+ self.add_token(self.root.session.filter_from_string(text))
+ except errors.ParserError as e:
+ dialogues.Error(text=f'syntax error: {e}').open()
+
+ def add_token(self, tokens):
+ with self.root.filter as filter:
+ tokens = [tok for tok in tokens if tok not in filter.t_head]
+ for tok in tokens:
+ # add token and frame
+ filter.t_head.append(tok)
+ filter.f_head.append(self.root.browser.frame)
+ if len(tokens):
+ # clear tails
+ filter.t_tail = []
+ filter.f_tail = []
+ # issue new frame
+ self.root.browser.frame = Frame()
+
+
+class EditToken(Action):
+ """Show a dialogue for editing a filter."""
+ text = kp.StringProperty('Edit token')
+
+ def apply(self, token):
+ sugg = self.root.session.storage.all(ns.bsn.Tag).label(node=False)
+ text = self.root.session.filter_to_string(token)
+ dlg = dialogues.TokenEdit(text=text, suggestions=sugg)
+ dlg.bind(on_ok=lambda obj: self.on_ok(token, obj))
+ dlg.open()
+
+ def on_ok(self, token, obj):
+ with self.root.filter as filter:
+ try:
+ tokens_from_text = self.root.session.filter_from_string(obj.text)
+ except errors.ParserError as e:
+ dialogues.Error(text=f'Invalid token: {e}').open()
+ return
+
+ # TODO: Check if this can be simplified
+ keep = False
+ for tok in tokens_from_text:
+ if tok == token: # don't remove if the token hasn't changed
+ keep = True
+
+ if token in filter.t_head and tok not in filter.t_head:
+ # insert after token into t_head
+ idx = filter.t_head.index(token)
+ frame = filter.f_head[idx]
+ filter.t_head.insert(idx + 1, tok)
+ filter.f_head.insert(idx + 1, frame.copy())
+ # remove from t_tail
+ if tok in filter.t_tail:
+ idx = filter.t_tail.index(tok)
+ filter.f_tail.pop(idx)
+ filter.t_tail.pop(idx)
+ elif token in filter.t_tail and tok not in filter.t_tail:
+ # insert after token into t_tail
+ idx = filter.t_tail.index(token)
+ frame = filter.f_tail[idx]
+ filter.t_tail.insert(idx + 1, tok)
+ filter.f_tail.insert(idx + 1, frame.copy())
+ # remove from t_head
+ if tok in filter.t_head:
+ idx = filter.t_head.index(tok)
+ filter.t_head.pop(idx)
+ filter.f_head.pop(idx)
+
+ # remove original token
+ if not keep and token in filter.t_head:
+ idx = filter.t_head.index(token)
+ filter.t_head.pop(idx)
+ filter.f_head.pop(idx)
+ if not keep and token in filter.t_tail:
+ idx = filter.t_tail.index(token)
+ filter.t_tail.pop(idx)
+ filter.f_tail.pop(idx)
+
+
+class RemoveToken(Action):
+ """Remove a filter."""
+ text = kp.StringProperty('Remove token')
+
+ def apply(self, token):
+ with self.root.filter as filter:
+ if token in filter.t_head:
+ idx = filter.t_head.index(token)
+ # remove frame
+ if idx < len(filter.t_head) - 1:
+ filter.f_head.pop(idx + 1)
+ self.root.browser.frame = Frame()
+ else:
+ self.root.browser.frame = filter.f_head.pop()
+ # remove token
+ filter.t_head.remove(token)
+
+ if token in filter.f_tail:
+ filter.f_tail.pop(filter.t_tail.index(token))
+ filter.t_tail.remove(token)
+
+
+class GoBack(Action):
+ """Remove the rightmost filter from the search."""
+ text = kp.StringProperty('Previous search')
+
+ def ktrigger(self, evt):
+ return Binding.check(evt, self.cfg('bindings', 'filter', 'go_back'))
+
+ def apply(self, n_steps=1):
+ with self.root.filter as filter:
+ for _ in range(n_steps):
+ if len(filter.t_head) > 0:
+ # move tokens
+ filter.t_tail.insert(0, filter.t_head.pop(-1))
+ # move frames
+ filter.f_tail.insert(0, self.root.browser.frame)
+ self.root.browser.frame = filter.f_head.pop(-1)
+
+
+class GoForth(Action):
+ """Add the rightmost filter to the search"""
+ text = kp.StringProperty('Next search')
+
+ def ktrigger(self, evt):
+ return Binding.check(evt, self.cfg('bindings', 'filter', 'go_forth'))
+
+ def apply(self, n_steps=1):
+ with self.root.filter as filter:
+ for _ in range(n_steps):
+ if len(filter.t_tail) > 0:
+ # move tokens
+ filter.t_head.append(filter.t_tail.pop(0))
+ # move frames
+ filter.f_head.append(self.root.browser.frame)
+ self.root.browser.frame = filter.f_tail.pop(0)
+
+
+class JumpToToken(Action):
+ """Jump to a filter token."""
+ text = kp.StringProperty('Jump to token')
+
+ def apply(self, token):
+ filter = self.root.filter
+ if token == filter.t_head[-1]:
+ pass
+ elif token in filter.t_head: # go
+ self.root.trigger('GoBack', len(filter.t_head) - filter.t_head.index(token) - 1)
+ elif token in filter.t_tail:
+ self.root.trigger('GoForth', filter.t_tail.index(token) + 1)
+
+
+class SearchmodeSwitch(Action, FilterAwareMixin):
+ """Switch between shingle and address search bar display."""
+ text = kp.StringProperty('Toggle searchbar mode')
+
+ def on_root(self, wx, root):
+ Action.on_root(self, wx, root)
+ FilterAwareMixin.on_root(self, wx, root)
+
+ def on_searchmode(self, filter, searchmode):
+ if self._image is not None:
+ if searchmode == filter.MODE_ADDRESS:
+ self._image.source = self.source_address
+ else:
+ self._image.source = self.source_shingles
+
+ def on_filter(self, wx, filter):
+ # remove old binding
+ if self.filter is not None:
+ self.filter.unbind(searchmode=self.on_searchmode)
+ # add new binding
+ self.filter = filter
+ if self.filter is not None:
+ self.filter.bind(searchmode=self.on_searchmode)
+ self.on_searchmode(self.filter, self.filter.searchmode)
+
+ def __del__(self):
+ if self.filter is not None:
+ self.filter.unbind(searchmode=self.on_searchmode)
+ self.filter = None
+
+ def apply(self):
+ with self.root.filter as filter:
+ if filter.searchmode == filter.MODE_SHINGLES:
+ filter.searchmode = filter.MODE_ADDRESS
+ else:
+ filter.searchmode = filter.MODE_SHINGLES
+
+
+## config ##
+
+# keybindings
+
+config.declare(('bindings', 'filter', 'add_token'),
+ config.Keybind(), Binding.simple('k', Binding.mCTRL, Binding.mREST), # Ctrl + k
+ __name__, AddToken.text.defaultvalue, AddToken.__doc__)
+
+config.declare(('bindings', 'filter', 'go_forth'),
+ config.Keybind(), Binding.simple(Binding.RIGHT, Binding.mALT, Binding.mREST), # Alt + right
+ __name__, GoForth.text.defaultvalue, GoForth.__doc__)
+
+config.declare(('bindings', 'filter', 'edit_once'), config.Keybind(),
+ Binding.multi(('l', Binding.mCTRL, Binding.mREST),
+ ('/', Binding.mREST, Binding.mALL)), # Ctrl + l, /
+ __name__, SearchByAddressOnce.text.defaultvalue, SearchByAddressOnce.__doc__)
+
+config.declare(('bindings', 'filter', 'go_back'), config.Keybind(),
+ Binding.multi((Binding.BACKSPACE, Binding.mCTRL, Binding.mREST),
+ (Binding.LEFT, Binding.mALT, Binding.mREST)), # Ctrl + backspace, Alt + left
+ __name__, GoBack.text.defaultvalue, GoBack.__doc__)
+
+## EOF ##
diff --git a/tagit/actions/grouping.kv b/tagit/actions/grouping.kv
new file mode 100644
index 0000000..9135c55
--- /dev/null
+++ b/tagit/actions/grouping.kv
@@ -0,0 +1,27 @@
+#:import resource_find kivy.resources.resource_find
+
+<CreateGroup>:
+ source: resource_find('atlas://grouping/create')
+ tooltip: 'Group items'
+
+<DissolveGroup>:
+ source: resource_find('atlas://grouping/ungroup')
+ tooltip: 'Ungroup items'
+
+<AddToGroup>:
+ source: resource_find('atlas://grouping/add')
+ tooltip: 'Add items to group'
+
+<OpenGroup>:
+ source: resource_find('atlas://grouping/group')
+ tooltip: 'Open Group'
+
+<RepresentGroup>:
+ source: resource_find('atlas://grouping/represent')
+ tooltip: 'Make group representative'
+
+<RemoveFromGroup>:
+ source: resource_find('atlas://grouping/remove')
+ tooltip: 'Remove from group'
+
+## EOF ##
diff --git a/tagit/actions/grouping.py b/tagit/actions/grouping.py
new file mode 100644
index 0000000..301af20
--- /dev/null
+++ b/tagit/actions/grouping.py
@@ -0,0 +1,263 @@
+"""
+
+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
+import random
+
+# kivy imports
+from kivy.lang import Builder
+import kivy.properties as kp
+
+# tagit imports
+from tagit import config, dialogues
+from tagit.utils import Frame, ns
+from tagit.utils.bsfs import Namespace, ast, uuid
+from tagit.widgets import Binding
+
+# inner-module imports
+from .action import Action
+
+# constants
+GROUP_PREFIX = Namespace('http://example.com/me/group')()
+
+# exports
+__all__ = []
+
+
+## code ##
+
+logger = logging.getLogger(__name__)
+
+# load kv
+Builder.load_file(os.path.join(os.path.dirname(__file__), 'grouping.kv'))
+
+# classes
+class CreateGroup(Action):
+ """Create a group from selected items."""
+ text = kp.StringProperty('Group items')
+
+ def ktrigger(self, evt):
+ return Binding.check(evt, self.cfg('bindings', 'grouping', 'create'))
+
+ def apply(self):
+ if self.cfg('ui', 'standalone', 'grouping', 'autoname'):
+ self.create_group()
+ else:
+ dlg = dialogues.SimpleInput()
+ dlg.bind(on_ok=lambda wx: self.create_group(wx.text))
+ dlg.open()
+
+ def create_group(self, label=None):
+ if len(self.root.browser.selection) > 1:
+ with self.root.browser as browser, \
+ self.root.session as session:
+ # create group
+ grp = session.storage.node(ns.bsn.Group,
+ getattr(GROUP_PREFIX, uuid.UUID()()))
+ if label is not None:
+ grp.set(ns.bsg.label, label)
+
+ # add items to group
+ ents = browser.unfold(browser.selection)
+ ents.set(ns.bse.group, grp)
+
+ # select a random representative
+ rep = random.choice(list(ents))
+ grp.set(ns.bsg.represented_by, rep)
+
+ # set selection and cursor to representative
+ # the representative will become valid after the search was re-applied
+ browser.selection.clear()
+ browser.selection.add(rep)
+ browser.cursor = rep
+
+ # notification
+ logger.info(f'Grouped {len(ents)} items')
+
+ # change event
+ session.dispatch('on_predicate_modified', ns.bse.group, ents, {grp})
+
+ # jump to cursor
+ # needs to be done *after* the browser was updated
+ self.root.trigger('JumpToCursor')
+
+
+class DissolveGroup(Action):
+ """Dissolve the selected group."""
+ text = kp.StringProperty('Dissolve group')
+
+ def ktrigger(self, evt):
+ return Binding.check(evt, self.cfg('bindings', 'grouping', 'ungroup'))
+
+ def apply(self):
+ with self.root.browser as browser, \
+ self.root.session as session:
+ cursor = browser.cursor
+ if cursor is not None and cursor in browser.folds:
+ grp = browser.folds[cursor].group
+ ents = session.storage.get(ns.bsn.Entity,
+ ast.filter.Any(ns.bse.group, ast.filter.Is(grp)))
+ #ents.remove(ns.bse.group, grp) # FIXME: mb/port
+ #grp.delete() # FIXME: mb/port
+
+ # FIXME: fix cursor and selection
+ # cursor: leave at item that was the representative
+ # selection: leave as is, select all group members if the cursor was selected
+ browser.frame = Frame()
+
+ # notification
+ logger.info(f'Ungrouped {len(ents)} items')
+
+ # change event
+ session.dispatch('on_predicate_modified', ns.bse.group, ents, {grp})
+
+ self.root.trigger('JumpToCursor')
+
+
+class AddToGroup(Action):
+ """Add an item to a group."""
+ text = kp.StringProperty('Add to group')
+
+ def ktrigger(self, evt):
+ return Binding.check(evt, self.cfg('bindings', 'grouping', 'add'))
+
+ def apply(self):
+ with self.root.browser as browser, \
+ self.root.session as session:
+ cursor = browser.cursor
+ if cursor is not None and cursor in browser.folds:
+ grp = browser.folds[cursor].group
+ ents = browser.unfold(browser.selection)
+
+ for obj in ents:
+ if obj == cursor:
+ # don't add group to itself
+ continue
+ obj.set(ns.bse.group, gr)
+
+ # all selected items will be folded, hence it becomes empty
+ if cursor in browser.selection:
+ browser.selection = {cursor}
+ else:
+ browser.selection.clear()
+
+ # change event
+ session.dispatch('on_predicate_modified', ns.bse.group, ents, {grp})
+
+
+class OpenGroup(Action):
+ """Show the items of the selected group."""
+ text = kp.StringProperty('Open group')
+
+ def ktrigger(self, evt):
+ return Binding.check(evt, self.cfg('bindings', 'grouping', 'open'))
+
+ def apply(self, cursor=None):
+ if cursor is None:
+ cursor = self.root.browser.cursor
+ elif cursor in self.root.browser.folds:
+ grp = self.root.browser.folds[cursor].group
+ self.root.trigger('AddToken', ast.filter.Any(
+ ns.bse.group, ast.filter.Is(grp)))
+
+
+class RepresentGroup(Action):
+ """Make the currently selected item the representative of the current group."""
+ text = kp.StringProperty('Represent')
+
+ def ktrigger(self, evt):
+ return Binding.check(evt, self.cfg('bindings', 'grouping', 'represent'))
+
+ def apply(self):
+ return # FIXME: mb/port
+ with self.root.browser as browser, \
+ self.root.filter as filter, \
+ self.root.session as session:
+ if browser.cursor is not None \
+ and len(filter.t_head) > 0 \
+ and filter.t_head[-1].predicate() == 'group':
+ # we know that the cursor is part of the group, since it matches the filter.
+ guid = filter.t_head[-1].condition()[0] # FIXME!
+ grp = session.storage.node(guid, ns.tagit.storage.Group)
+ grp.represented_by = browser.cursor
+ logger.info(f'{browser.cursor} now represents {grp}')
+
+
+class RemoveFromGroup(Action):
+ """Remove the selected item from the group"""
+ text = kp.StringProperty('Remove from group')
+
+ def ktrigger(self, evt):
+ return Binding.check(evt, self.cfg('bindings', 'grouping', 'remove'))
+
+ def apply(self):
+ return # FIXME: mb/port
+ with self.root.browser as browser, \
+ self.root.filter as filter, \
+ self.root.session as session:
+ if len(filter.t_head) > 0 and \
+ filter.t_head[-1].predicate() == 'group':
+ guid = filter.t_head[-1].condition()[0]
+ grp = session.storage.group(guid)
+ ents = self.root.session.storage.entities(browser.unfold(browser.selection))
+ ents -= grp
+
+ try:
+ rep = grp.represented_by
+ if grp not in rep.group:
+ # representative was removed, pick a random one
+ random.choice(list(rep.members())).represent(grp) # FIXME: member relation?
+ # grp.represented_by = random.choice(list(rep.members()))
+
+ except ValueError:
+ # group is now empty
+ pass
+
+ # set cursor to a non-selected (hence non-removed) item
+ # clear the selection
+ browser.cursor = browser.neighboring_unselected()
+ browser.selection = [browser.cursor] if browser.cursor is not None else []
+
+ # change event
+ session.dispatch('on_predicate_modified', 'group', items, {grp})
+
+ self.root.trigger('JumpToCursor')
+
+
+## config ##
+
+config.declare(('ui', 'standalone', 'grouping', 'autoname'), config.Bool(), True,
+ __name__, 'Auto-name groups', 'If enabled, group names are auto-generated (resulting in somewhat cryptical names). If disabled, a name can be specified when creating new groups.')
+
+# keybindings
+
+config.declare(('bindings', 'grouping', 'create'),
+ config.Keybind(), Binding.simple('g', Binding.mCTRL, Binding.mREST),
+ __name__, CreateGroup.text.defaultvalue, CreateGroup.__doc__)
+
+config.declare(('bindings', 'grouping', 'ungroup'),
+ config.Keybind(), Binding.simple('g', [Binding.mALT, Binding.mCTRL], Binding.mREST),
+ __name__, DissolveGroup.text.defaultvalue, DissolveGroup.__doc__)
+
+config.declare(('bindings', 'grouping', 'add'),
+ config.Keybind(), Binding.simple('h', [Binding.mCTRL], Binding.mREST),
+ __name__, AddToGroup.text.defaultvalue, AddToGroup.__doc__)
+
+config.declare(('bindings', 'grouping', 'open'),
+ config.Keybind(), Binding.simple('g', None, Binding.mREST),
+ __name__, OpenGroup.text.defaultvalue, OpenGroup.__doc__)
+
+config.declare(('bindings', 'grouping', 'represent'),
+ config.Keybind(), Binding.simple('g', [Binding.mCTRL, Binding.mSHIFT], Binding.mREST),
+ __name__, RepresentGroup.text.defaultvalue, RepresentGroup.__doc__)
+
+config.declare(('bindings', 'grouping', 'remove'),
+ config.Keybind(), Binding.simple('h', [Binding.mSHIFT, Binding.mCTRL], Binding.mREST),
+ __name__, RemoveFromGroup.text.defaultvalue, RemoveFromGroup.__doc__)
+
+## EOF ##
diff --git a/tagit/actions/misc.kv b/tagit/actions/misc.kv
new file mode 100644
index 0000000..f9d5157
--- /dev/null
+++ b/tagit/actions/misc.kv
@@ -0,0 +1,35 @@
+#:import resource_find kivy.resources.resource_find
+
+<Menu>:
+ source: resource_find('atlas://misc/menu')
+ tooltip: 'Open the menu'
+
+<ShellDrop>:
+ source: resource_find('atlas://misc/shell')
+ tooltip: 'Open a terminal shell'
+
+<OpenExternal>:
+ source: resource_find('atlas://misc/open_external')
+ tooltip: 'Open selected items in an external application'
+
+<ShowConsole>:
+ source: resource_find('atlas://misc/console')
+ tooltip: 'Open the log console'
+
+<ShowHelp>:
+ source: resource_find('atlas://misc/help')
+ tooltip: 'Open the help'
+
+<ShowSettings>:
+ source: resource_find('atlas://misc/settings')
+ tooltip: 'Open the settings menu'
+
+<ClipboardCopy>:
+ source: resource_find('atlas://misc/clip_copy')
+ tooltip: 'Copy selected items to the clipboard'
+
+<ClipboardPaste>:
+ source: resource_find('atlas://misc/clip_paste')
+ tooltip: 'Import files from the clipboard'
+
+## EOF ##
diff --git a/tagit/actions/misc.py b/tagit/actions/misc.py
new file mode 100644
index 0000000..f74e0f9
--- /dev/null
+++ b/tagit/actions/misc.py
@@ -0,0 +1,178 @@
+"""
+
+Part of the tagit module.
+A copy of the license is provided with the project.
+Author: Matthias Baumgartner, 2022
+"""
+# standard imports
+import code
+import logging
+import os
+import sys
+
+# external imports
+import webbrowser
+
+# kivy imports
+from kivy.core.clipboard import Clipboard
+from kivy.lang import Builder
+import kivy.properties as kp
+
+# tagit imports
+from tagit import config
+from tagit.utils import fileopen
+from tagit.widgets import Binding
+
+# inner-module imports
+from .action import Action
+
+# constants
+HELP_URL = 'https://www.bsfs.io/'
+
+# exports
+__all__ = []
+
+
+## code ##
+
+logger = logging.getLogger(__name__)
+
+# load kv
+Builder.load_file(os.path.join(os.path.dirname(__file__), 'misc.kv'))
+
+# classes
+class Menu(Action):
+ """Open the menu."""
+ text = kp.StringProperty('Menu')
+
+ def ktrigger(self, evt):
+ return Binding.check(evt, self.cfg('bindings', 'misc', 'menu'))
+
+ def apply(self):
+ x = self.pos[0] + self.width
+ y = self.pos[1] + self.height
+ self.root.context.show(x, y)
+
+
+class ShellDrop(Action):
+ """Open a terminal shell."""
+ text = kp.StringProperty('Shell')
+
+ def apply(self):
+ from pprint import pprint as pp
+ loc = globals()
+ loc.update(locals())
+ code.interact(banner='tagit shell', local=loc)
+ if loc.get('abort', False):
+ sys.exit(1)
+
+
+
+class OpenExternal(Action):
+ """Open the selected items in an external application."""
+ text = kp.StringProperty('Open')
+
+ def ktrigger(self, evt):
+ # FIXME: Triggered on Shift + Click (Interferes with selection!)
+ # Triggered on <Enter> when tags are edited.
+ return Binding.check(evt, self.cfg('bindings', 'misc', 'open'))
+
+ def apply(self):
+ return # FIXME: mb/port
+ with self.root.browser as browser:
+ if browser.cursor is None:
+ logger.error('No file selected')
+ elif os.path.exists(browser.cursor.path):
+ fileopen(browser.cursor.path)
+ else:
+ logger.error('File unavailable')
+
+
+class ShowConsole(Action):
+ """Open the log console."""
+ text = kp.StringProperty('Console')
+
+ def apply(self):
+ self.root.status.console()
+
+
+class ShowHelp(Action):
+ """Show some help."""
+ text = kp.StringProperty('Help')
+
+ def ktrigger(self, evt):
+ return Binding.check(evt, self.cfg('bindings', 'misc', 'help'))
+
+ def apply(self):
+ webbrowser.open(HELP_URL)
+
+
+class ShowSettings(Action):
+ """Open the settings menu."""
+ text = kp.StringProperty('Settings')
+
+ def ktrigger(self, evt):
+ return Binding.check(evt, self.cfg('bindings', 'misc', 'settings'))
+
+ def apply(self):
+ from kivy.app import App
+ App.get_running_app().open_settings()
+
+
+class ClipboardCopy(Action):
+ """Copy selected items into the clipboard."""
+ text = kp.StringProperty('Copy to clipboard')
+
+ def ktrigger(self, evt):
+ return Binding.check(evt, self.cfg('bindings', 'clipboard', 'copy'))
+
+ def apply(self):
+ return # FIXME: mb/port
+ browser = self.root.browser
+ paths = [obj.path for obj in browser.selection]
+ Clipboard.copy('\n'.join(paths))
+
+
+class ClipboardPaste(Action):
+ """Import items from the clipboard."""
+ text = kp.StringProperty('Paste from clipboard')
+
+ def ktrigger(self, evt):
+ return Binding.check(evt, self.cfg('bindings', 'clipboard', 'paste'))
+
+ def apply(self):
+ return # FIXME: mb/port
+ paths = Clipboard.paste()
+ paths = paths.split('\n')
+ self.root.trigger('ImportObjects', paths)
+
+
+## config ##
+
+# keybindings
+
+config.declare(('bindings', 'misc', 'menu'),
+ config.Keybind(), Binding.simple(Binding.CMD, None, Binding.mALL),
+ __name__, Menu.text.defaultvalue, Menu.__doc__)
+
+config.declare(('bindings', 'misc', 'open'),
+ config.Keybind(), Binding.simple(Binding.ENTER, None, Binding.mALL),
+ __name__, OpenExternal.text.defaultvalue, OpenExternal.__doc__)
+
+config.declare(('bindings', 'misc', 'help'),
+ config.Keybind(), Binding.simple('/', Binding.mSHIFT),
+ __name__, ShowHelp.text.defaultvalue, ShowHelp.__doc__)
+
+config.declare(('bindings', 'misc', 'settings'),
+ config.Keybind(), Binding.simple(Binding.F1), # also the kivy default
+ __name__, ShowSettings.text.defaultvalue, ShowSettings.__doc__)
+
+config.declare(('bindings', 'clipboard', 'copy'),
+ config.Keybind(), Binding.simple('c', Binding.mCTRL),
+ __name__, ClipboardCopy.text.defaultvalue, ClipboardCopy.__doc__)
+
+config.declare(('bindings', 'clipboard', 'paste'),
+ config.Keybind(), Binding.simple('v', Binding.mCTRL),
+ __name__, ClipboardPaste.text.defaultvalue, ClipboardPaste.__doc__)
+
+## EOF ##
diff --git a/tagit/actions/search.kv b/tagit/actions/search.kv
new file mode 100644
index 0000000..00f6d6d
--- /dev/null
+++ b/tagit/actions/search.kv
@@ -0,0 +1,25 @@
+#:import resource_find kivy.resources.resource_find
+
+<Search>:
+ source: resource_find('atlas://search/search')
+ tooltip: 'Apply the current search filter'
+
+<ShowSelected>:
+ source: resource_find('atlas://search/exclusive_filter')
+ tooltip: 'Show only selected items'
+
+<RemoveSelected>:
+ source: resource_find('atlas://search/exclude_filter')
+ tooltip: 'Exclude selected items'
+
+<SortKey>:
+ source: resource_find('atlas://search/sort_key')
+ tooltip: 'Specify the sort key'
+
+<SortOrder>:
+ source_up: resource_find('atlas://search/sort_order_up')
+ source_down: resource_find('atlas://search/sort_order_down')
+ source: self.source_up
+ tooltip: 'Sort order'
+
+## EOF ##
diff --git a/tagit/actions/search.py b/tagit/actions/search.py
new file mode 100644
index 0000000..dd6c50c
--- /dev/null
+++ b/tagit/actions/search.py
@@ -0,0 +1,335 @@
+"""
+
+Part of the tagit module.
+A copy of the license is provided with the project.
+Author: Matthias Baumgartner, 2022
+"""
+# standard imports
+from functools import partial
+import os
+
+# kivy imports
+from kivy.cache import Cache
+from kivy.lang import Builder
+import kivy.properties as kp
+
+# tagit imports
+from tagit import config, dialogues
+from tagit.external.kivy_garden.contextmenu import ContextMenu
+from tagit.utils import clamp, errors
+from tagit.utils import ns
+from tagit.utils.bsfs import ast
+from tagit.widgets import Binding
+from tagit.widgets.filter import FilterAwareMixin
+from tagit.widgets.session import StorageAwareMixin, ConfigAwareMixin
+#from tagit.ai.features.content import ContentFeature, FeatureBuilder # FIXME: mb/port
+#from tagit.parsing.search import sortkeys # FIXME: mb/port
+
+
+# inner-module imports
+from .action import Action
+
+# exports
+__all__ = []
+
+
+## code ##
+
+# load kv
+Builder.load_file(os.path.join(os.path.dirname(__file__), 'search.kv'))
+
+# classes
+class Search(Action, StorageAwareMixin, ConfigAwareMixin):
+ """Apply the current search filter and update the browser."""
+ text = kp.StringProperty('Search')
+
+ # internal category for the cache
+ _CACHE_CATEGORY = 'tagit.search'
+
+ def ktrigger(self, evt):
+ return Binding.check(evt, self.cfg('bindings', 'search', 'search'))
+
+ def on_root(self, wx, root):
+ Action.on_root(self, wx, root)
+ ConfigAwareMixin.on_root(self, wx, root)
+ StorageAwareMixin.on_root(self, wx, root)
+
+ def on_config_changed(self, session, key, value):
+ """Update cache settings."""
+ if self._CACHE_CATEGORY not in Cache._categories:
+ pass
+ elif key == ('ui', 'standalone', 'search', 'cache_items'):
+ value = None if value <= 0 else value
+ Cache._categories[self._CACHE_CATEGORY]['limit'] = value
+ elif key == ('ui', 'standalone', 'search', 'cache_timeout'):
+ value = None if value <= 0 else value
+ Cache._categories[self._CACHE_CATEGORY]['timeout'] = value
+
+ def on_cfg(self, wx, cfg):
+ """Initialize the cache."""
+ if self._CACHE_CATEGORY not in Cache._categories:
+ n_items = self.cfg('ui', 'standalone', 'search', 'cache_items')
+ n_items = None if n_items <= 0 else n_items
+ timeout = self.cfg('ui', 'standalone', 'search', 'cache_timeout')
+ timeout = None if timeout <= 0 else timeout
+ Cache.register(self._CACHE_CATEGORY, n_items, timeout)
+
+ def on_storage_modified(self, sender):
+ # clear the whole cache
+ Cache.remove(self._CACHE_CATEGORY, None)
+
+ def on_predicate_modified(self, sender, predicate, objects, diff):
+ Cache.remove(self._CACHE_CATEGORY, None) # clears the whole cache
+ self.apply()
+ return # FIXME: mb/port
+ tbd = set()
+ # walk through cache
+ for ast, sort in Cache._objects[self._CACHE_CATEGORY]:
+ # check ast
+ if ast is not None:
+ for token in ast:
+ if token.predicate() == predicate:
+ if predicate in ('tag', 'group') and \
+ len(set(diff) & set(token.condition())) == 0:
+ # tag predicate but the tag in question was not changed; skip
+ continue
+ tbd.add((ast, sort))
+ break # no need to search further
+
+ # check sort
+ if sort is not None:
+ if sort.predicate() == predicate:
+ tbd.add((ast, sort))
+
+ for key in tbd:
+ Cache.remove(self._CACHE_CATEGORY, key)
+
+ # re-apply searches
+ self.apply()
+
+ def apply(self):
+ browser = self.root.browser
+ filter = self.root.filter
+ session = self.root.session
+
+ with browser:
+ # get query
+ query, sort = filter.get_query()
+ # log search
+ # FIXME: mb/port/log
+ #session.log.log_search(
+ # 'filter',
+ # session.storage.lib.meta,
+ # (filter.t_head, filter.t_tail),
+ # (filter.f_head + [browser.frame], filter.f_tail),
+ # )
+
+ # apply search or fetch it from the cache
+ items = Cache.get(self._CACHE_CATEGORY, (query, sort), None)
+ if items is None:
+ # FIXME: mb/port: consider sort
+ items = list(session.storage.sorted(ns.bsn.Entity, query))
+ Cache.append(self._CACHE_CATEGORY, (query, sort), items)
+
+ # apply search order because it's cheaper to do it here rather
+ # than in the backend (also see uix.kivy.filter.get_query).
+ items = list(reversed(items[:])) if filter.sortdir else items[:]
+ # update browser
+ browser.set_items(items)
+
+
+class ShowSelected(Action):
+ """Show only selected items."""
+ text = kp.StringProperty('Selected only')
+
+ def ktrigger(self, evt):
+ return Binding.check(evt, self.cfg('bindings', 'search', 'exclusive'))
+
+ def apply(self):
+ with self.root.browser as browser:
+ if len(browser.selection) == 0:
+ # silently ignore if no images selected
+ pass
+ elif len(browser.selection) == 1 and list(browser.selection)[0] in browser.folds:
+ # selection is a group
+ self.root.trigger('OpenGroup', list(browser.selection)[0])
+ else:
+ token = ast.filter.IsIn(browser.unfold(browser.selection))
+ self.root.trigger('AddToken', token)
+
+
+class RemoveSelected(Action):
+ """Exclude selected items."""
+ text = kp.StringProperty('Exclude selection')
+
+ def ktrigger(self, evt):
+ return Binding.check(evt, self.cfg('bindings', 'search', 'remove'))
+
+ def apply(self):
+ with self.root.browser as browser:
+ if len(browser.selection) == 0:
+ # silently ignore if no images selected
+ pass
+ else:
+ new_cursor = browser.neighboring_unselected()
+ token = ast.filter.IsNotIn(browser.unfold(browser.selection))
+ self.root.trigger('AddToken', token)
+ # fix frame
+ browser.cursor = new_cursor
+ browser.selection = {browser.cursor} if browser.cursor is not None else set()
+ self.root.trigger('JumpToCursor')
+
+
+class SortKey(Action):
+ """Select by which property items are ordered."""
+ text = kp.StringProperty('Sort by')
+
+ def apply(self, predicate=None):
+ if predicate is None:
+ x = self.pos[0] + self.width
+ y = self.pos[1] + self.height
+ self.menu.show(x, y)
+ else:
+ self.set_sortkey(predicate)
+
+ def on_root(self, wx, root):
+ super(SortKey, self).on_root(wx, root)
+
+ # Order is essential here:
+ # 1. the menu has to be created
+ # 2. the menu has to be attached to a parent
+ # 3. the menu has to be populated
+ # The visibility has to be triggered after (2) or (3)
+ self.menu = ContextMenu(
+ bounding_box_widget = self.root,
+ cancel_handler_widget = self.root)
+ self.root.add_widget(self.menu)
+ self.menu._on_visible(False)
+
+ # TODO: The whole sortkeys setup is rather brittle
+ # e.g. what happens if new features become available at runtime?
+
+ # default sortkeys
+ return # FIXME: mb/port
+
+ options = sortkeys.scope.library | sortkeys.typedef.anchored # FIXME: mb/port
+ # apply whitelist and blacklist config
+ options -= set(self.cfg('ui', 'standalone', 'search', 'sort_blacklist'))
+ whitelist = set(self.cfg('ui', 'standalone', 'search', 'sort_whitelist'))
+ whitelist = whitelist if len(whitelist) else options
+ options &= whitelist
+ # TODO: If there are several versions of the same feature class, keep only the most frequent
+ # * get feature predicates and their feature class (i.e. name)
+ # * if needed, get their frequencies via Features.Entities(ctrl.stor.num, fid)
+ # For now, all known features are used.
+
+ # populate menu
+ for sortkey in sorted(options):
+ text = sortkey
+ if ContentFeature.is_feature_id(sortkey):
+ text = FeatureBuilder.class_from_guid(sortkey).friendly_guid(sortkey)
+
+ self.menu.add_text_item(
+ text=text,
+ on_release=partial(self.release_wrapper, sortkey)
+ )
+
+ def release_wrapper(self, sortkey, *args):
+ # hide
+ self.menu.hide()
+ # trigger event
+ self.set_sortkey(sortkey)
+
+ def set_sortkey(self, predicate):
+ return # FIXME: mb/port
+ with self.root.filter as filter:
+ try:
+ # TODO: What if a predicate accepts several types (e.g. num and anchored)
+ if predicate in sortkeys.typedef.anchored:
+ cursor = self.root.browser.cursor
+ if cursor is None:
+ raise errors.UserError('an image needs to be selected for similarity sort.')
+ # TODO: We normally want the anchored search to be sorted most similar
+ # to least similar (sortdir=False). We could adjust the sortdir automatically.
+ # Note that VFilterAction_SortOrder would *not* get notified automatically.
+ filter.sortkey = partial(ast.AnchoredSort, predicate, cursor.guid)
+ elif predicate in sortkeys.typedef.numerical:
+ filter.sortkey = partial(ast.NumericalSort, predicate)
+ elif predicate in sortkeys.typedef.alphabetical:
+ filter.sortkey = partial(ast.AlphabeticalSort, predicate)
+ else:
+ raise errors.UserError('invalid sort key selected')
+
+ except Exception as e:
+ dialogues.Error(text=str(e)).open()
+
+ # stick to cursor
+ self.root.trigger('JumpToCursor')
+
+
+class SortOrder(Action, FilterAwareMixin):
+ """Switch between ascending and descending order."""
+ text = kp.StringProperty('Toggle sort order')
+
+ def on_root(self, wx, root):
+ Action.on_root(self, wx, root)
+ FilterAwareMixin.on_root(self, wx, root)
+
+ def on_sortdir(self, wx, sortdir):
+ if self._image is not None:
+ self._image.source = self.source_down if sortdir else self.source_up
+
+ def on_filter(self, wx, filter):
+ # remove old binding
+ if self.filter is not None:
+ self.filter.unbind(sortdir=self.on_sortdir)
+ # add new binding
+ self.filter = filter
+ if self.filter is not None:
+ self.filter.bind(sortdir=self.on_sortdir)
+ self.on_sortdir(self.filter, self.filter.sortdir)
+
+ def __del__(self):
+ # remove old binding
+ if self.filter is not None:
+ self.filter.unbind(sortdir=self.on_sortdir)
+ self.filter = None
+
+ def apply(self):
+ with self.root.filter as filter, \
+ self.root.browser as browser:
+ filter.sortdir = not filter.sortdir
+ # keep the same field of view as before
+ browser.offset = clamp(browser.n_items - (browser.offset + browser.page_size),
+ browser.max_offset)
+
+
+## config ##
+
+config.declare(('ui', 'standalone', 'search', 'sort_blacklist'), config.List(config.String()), [],
+ __name__, 'Blacklisted sortkeys', 'Sort keys that will not be shown in the sort selection. This does not affect whitelisted keys.')
+
+config.declare(('ui', 'standalone', 'search', 'sort_whitelist'), config.List(config.String()), [],
+ __name__, 'Whitelisted sortkeys', 'Sort keys that will always be shown in the sort selection. Overrules blacklisted keys.')
+
+config.declare(('ui', 'standalone', 'search', 'cache_items'), config.Unsigned(), 0,
+ __name__, 'Search cache size', 'Number of searches that are held in cache. Zero means no limit.')
+
+config.declare(('ui', 'standalone', 'search', 'cache_timeout'), config.Unsigned(), 0,
+ __name__, 'Search cache timeout', 'Number of seconds until searches are discarded from the search cache. Zero means no limit.')
+
+# keybindings
+
+config.declare(('bindings', 'search', 'search'),
+ config.Keybind(), Binding.simple(Binding.F5),
+ __name__, Search.text.defaultvalue, Search.__doc__)
+
+config.declare(('bindings', 'search', 'exclusive'),
+ config.Keybind(), Binding.simple(Binding.ENTER, Binding.mCTRL, Binding.mREST),
+ __name__, ShowSelected.text.defaultvalue, ShowSelected.__doc__)
+
+config.declare(('bindings', 'search', 'remove'),
+ config.Keybind(), Binding.simple(Binding.DEL, None, Binding.mALL),
+ __name__, RemoveSelected.text.defaultvalue, RemoveSelected.__doc__)
+
+## EOF ##
diff --git a/tagit/actions/session.kv b/tagit/actions/session.kv
new file mode 100644
index 0000000..21807b2
--- /dev/null
+++ b/tagit/actions/session.kv
@@ -0,0 +1,7 @@
+#:import resource_find kivy.resources.resource_find
+
+<LoadSession>:
+ source: resource_find('atlas://session/open')
+ tooltip: 'Load a session'
+
+## EOF ##
diff --git a/tagit/actions/session.py b/tagit/actions/session.py
new file mode 100644
index 0000000..3c5ad39
--- /dev/null
+++ b/tagit/actions/session.py
@@ -0,0 +1,56 @@
+"""
+
+Part of the tagit module.
+A copy of the license is provided with the project.
+Author: Matthias Baumgartner, 2022
+"""
+# standard imports
+import os
+
+# kivy imports
+from kivy.lang import Builder
+import kivy.properties as kp
+
+# tagit imports
+from tagit import config, dialogues
+from tagit.config.loader import load_settings
+
+# inner-module imports
+from .action import Action
+
+# exports
+__all__ = []
+
+
+## code ##
+
+# load kv
+Builder.load_file(os.path.join(os.path.dirname(__file__), 'session.kv'))
+
+# classes
+class LoadSession(Action):
+ """Load a session from a project file."""
+ text = kp.StringProperty('Load')
+
+ def apply(self):
+ """Open a file load dialogue to select a session file."""
+ dlg = dialogues.FilePicker(title='Select a session file to load')
+ dlg.bind(on_ok=self.load_from_path)
+ dlg.open()
+
+ def load_from_path(self, wx):
+ """Load a session from *path*."""
+ with self.root.session as session:
+ try:
+ if not os.path.exists(wx.path) or not os.path.isfile(wx.path):
+ raise FileNotFoundError(wx.path)
+
+ # load config from path
+ cfg = load_settings(wx.path, verbose=self.cfg('session', 'verbose'))
+ session.load(cfg)
+
+ except Exception as e:
+ dialogues.Error(text=f'The file cannot be loaded ({e})').open()
+
+
+## EOF ##
diff --git a/tagit/actions/tagging.kv b/tagit/actions/tagging.kv
new file mode 100644
index 0000000..53ba926
--- /dev/null
+++ b/tagit/actions/tagging.kv
@@ -0,0 +1,11 @@
+#:import resource_find kivy.resources.resource_find
+
+<AddTag>:
+ source: resource_find('atlas://objects/add_tag')
+ tooltip: 'Add tags to items'
+
+<EditTag>:
+ source: resource_find('atlas://objects/edit_tag')
+ tooltip: 'Edit tags of items'
+
+## EOF ##
diff --git a/tagit/actions/tagging.py b/tagit/actions/tagging.py
new file mode 100644
index 0000000..ab25cb8
--- /dev/null
+++ b/tagit/actions/tagging.py
@@ -0,0 +1,167 @@
+"""
+
+Part of the tagit module.
+A copy of the license is provided with the project.
+Author: Matthias Baumgartner, 2022
+"""
+# standard imports
+from functools import reduce, partial
+import operator
+import os
+
+# external imports
+import urllib.parse
+
+# kivy imports
+from kivy.lang import Builder
+import kivy.properties as kp
+
+# tagit imports
+from tagit import config, dialogues
+from tagit.utils import ns
+from tagit.utils.bsfs import Namespace
+from tagit.widgets import Binding
+
+# inner-module imports
+from .action import Action
+
+# constants
+TAGS_SEPERATOR = ','
+TAG_PREFIX = Namespace('http://example.com/me/tag')()
+
+# exports
+__all__ = []
+
+
+## code ##
+
+# load kv
+Builder.load_file(os.path.join(os.path.dirname(__file__), 'tagging.kv'))
+
+# classes
+class AddTag(Action):
+ """Add tags to the selected items."""
+ text = kp.StringProperty('Add tag')
+
+ def ktrigger(self, evt):
+ return Binding.check(evt, self.cfg('bindings', 'objects', 'add_tag'))
+
+ def apply(self):
+ if len(self.root.browser.selection) > 0:
+ tags = self.root.session.storage.all(ns.bsn.Tag).label(node=True)
+ dlg = dialogues.SimpleInput(
+ suggestions=set(tags.values()),
+ suggestion_sep=TAGS_SEPERATOR)
+ dlg.bind(on_ok=partial(self.add_tags, tags))
+ dlg.open()
+
+ else:
+ dialogues.Error(text='You must select some images first.').open()
+
+ def add_tags(self, tags, wx):
+ # user-specified labels
+ labels = {t.strip() for t in wx.text.split(TAGS_SEPERATOR) if len(t.strip())}
+ # label to tag mapping
+ lut = {label: tag for tag, label in tags.items()}
+ # get previous tags
+ tags = {lut[lbl] for lbl in labels if lbl in lut}
+ # create new tag nodes and set their label
+ # FIXME: deny adding tags if tag vocabulary is fixed (ontology case)
+ # FIXME: replace with proper tag factory
+ for lbl in labels:
+ if lbl not in lut:
+ tag = self.root.session.storage.node(ns.bsn.Tag,
+ getattr(TAG_PREFIX, urllib.parse.quote(lbl)))
+ tag.set(ns.bst.label, lbl)
+ tags.add(tag)
+ with self.root.browser as browser, \
+ self.root.session as session:
+ # get objects
+ ents = browser.unfold(browser.selection)
+ # collect tags
+ tags = reduce(operator.add, tags, self.root.session.storage.empty(ns.bsn.Tag)) # FIXME: mb/port: pass set once supported by Nodes.set
+ # set tags
+ ents.set(ns.bse.tag, tags)
+ session.dispatch('on_predicate_modified', ns.bse.tag, ents, tags)
+ # cursor and selection might become invalid. Will be fixed in Browser.
+
+
+class EditTag(Action):
+ """Edit tags of the selected items"""
+ text = kp.StringProperty('Edit tags')
+
+ def ktrigger(self, evt):
+ return Binding.check(evt, self.cfg('bindings', 'objects', 'edit_tag'))
+
+ def apply(self):
+ with self.root.browser as browser:
+ if len(browser.selection) > 0:
+ # get all known tags
+ all_tags = self.root.session.storage.all(ns.bsn.Tag).label(node=True)
+ # get selection tags
+ ent_tags = browser.unfold(browser.selection).tag.label(node=True)
+ if len(ent_tags) == 0:
+ text = ''
+ else:
+ sep = TAGS_SEPERATOR + ' '
+ text = sep.join(sorted(set.intersection(*ent_tags.values())))
+
+ dlg = dialogues.SimpleInput(
+ text=text,
+ suggestions=set(all_tags.values()),
+ suggestion_sep=TAGS_SEPERATOR,
+ )
+ dlg.bind(on_ok=partial(self.edit_tag, ent_tags, all_tags))
+ dlg.open()
+
+ else:
+ dialogues.Error(text='You must select some images first.').open()
+
+ def edit_tag(self, original, tags, wx):
+ """Add or remove tags from images.
+ *original* and *modified* are strings, split at *TAGS_SEPERATOR*.
+ Tags are added and removed with respect to the difference between those two sets.
+ """
+ # user-specified labels
+ labels = {t.strip() for t in wx.text.split(TAGS_SEPERATOR) if len(t.strip())}
+ # label differences
+ original_labels = {lbl for lbls in original.values() for lbl in lbls}
+ removed_labels = original_labels - labels
+ added_labels = labels - original_labels
+ # get tags of removed labels
+ removed = {tag for tag, lbl in tags.items() if lbl in removed_labels}
+ removed = reduce(operator.add, removed, self.root.session.storage.empty(ns.bsn.Tag))
+ # get tags of added labels
+ # FIXME: deny adding tags if tag vocabulary is fixed (ontology case)
+ lut = {label: tag for tag, label in tags.items()}
+ added = {lut[lbl] for lbl in added_labels if lbl in lut}
+ # FIXME: replace with proper tag factory
+ for lbl in added_labels:
+ if lbl not in lut:
+ tag = self.root.session.storage.node(ns.bsn.Tag,
+ getattr(TAG_PREFIX, urllib.parse.quote(lbl)))
+ tag.set(ns.bst.label, lbl)
+ added.add(tag)
+ added = reduce(operator.add, added, self.root.session.storage.empty(ns.bsn.Tag))
+ # apply differences
+ with self.root.browser as browser, \
+ self.root.session as session:
+ ents = browser.unfold(browser.selection)
+ ents.set(ns.bse.tag, added)
+ #ents.remove(ns.bse.tag, removed) # FIXME: mb/port
+ session.dispatch('on_predicate_modified', ns.bse.tag, ents, added | removed)
+ # cursor and selection might become invalid. Will be fixed in Browser.
+
+## config ##
+
+# keybindings
+
+config.declare(('bindings', 'objects', 'add_tag'),
+ config.Keybind(), Binding.simple('t', Binding.mCTRL, Binding.mSHIFT),
+ __name__, AddTag.text.defaultvalue, AddTag.__doc__)
+
+config.declare(('bindings', 'objects', 'edit_tag'),
+ config.Keybind(), Binding.simple('e', Binding.mCTRL),
+ __name__, EditTag.text.defaultvalue, EditTag.__doc__)
+
+## EOF ##