diff options
author | Matthias Baumgartner <dev@igsor.net> | 2023-02-08 21:29:58 +0100 |
---|---|---|
committer | Matthias Baumgartner <dev@igsor.net> | 2023-02-08 21:29:58 +0100 |
commit | bf98c062ece242a5fc56de0f1adbc12f0588809a (patch) | |
tree | 417f4fe5af06bfeb028e96c809bb23bf58bb4e29 /tagit/actions | |
parent | 547124605a9f86469a547fcaf38dc18ae57b707f (diff) | |
parent | f39d577421bc2e4b041b5d22e788f4615ef78d77 (diff) | |
download | tagit-bf98c062ece242a5fc56de0f1adbc12f0588809a.tar.gz tagit-bf98c062ece242a5fc56de0f1adbc12f0588809a.tar.bz2 tagit-bf98c062ece242a5fc56de0f1adbc12f0588809a.zip |
Merge branch 'mb/port/desktop' into develop
Diffstat (limited to 'tagit/actions')
-rw-r--r-- | tagit/actions/__init__.py | 110 | ||||
-rw-r--r-- | tagit/actions/action.kv | 45 | ||||
-rw-r--r-- | tagit/actions/action.py | 257 | ||||
-rw-r--r-- | tagit/actions/browser.kv | 99 | ||||
-rw-r--r-- | tagit/actions/browser.py | 628 | ||||
-rw-r--r-- | tagit/actions/filter.kv | 41 | ||||
-rw-r--r-- | tagit/actions/filter.py | 314 | ||||
-rw-r--r-- | tagit/actions/grouping.kv | 27 | ||||
-rw-r--r-- | tagit/actions/grouping.py | 262 | ||||
-rw-r--r-- | tagit/actions/misc.kv | 35 | ||||
-rw-r--r-- | tagit/actions/misc.py | 178 | ||||
-rw-r--r-- | tagit/actions/planes.kv | 15 | ||||
-rw-r--r-- | tagit/actions/planes.py | 57 | ||||
-rw-r--r-- | tagit/actions/search.kv | 25 | ||||
-rw-r--r-- | tagit/actions/search.py | 335 | ||||
-rw-r--r-- | tagit/actions/session.kv | 7 | ||||
-rw-r--r-- | tagit/actions/session.py | 56 | ||||
-rw-r--r-- | tagit/actions/tagging.kv | 11 | ||||
-rw-r--r-- | tagit/actions/tagging.py | 162 |
19 files changed, 2664 insertions, 0 deletions
diff --git a/tagit/actions/__init__.py b/tagit/actions/__init__.py new file mode 100644 index 0000000..b2ab6bd --- /dev/null +++ b/tagit/actions/__init__.py @@ -0,0 +1,110 @@ +""" + +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 planes +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, + ## planes + 'ShowDashboard': planes.ShowDashboard, + 'ShowBrowsing': planes.ShowBrowsing, + 'ShowCodash': planes.ShowCodash, + ## 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..5352964 --- /dev/null +++ b/tagit/actions/action.kv @@ -0,0 +1,45 @@ + +<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: [] + + # decoration + canvas.before: + Color: + rgba: 17 / 255, 32 / 255, 148 / 255, self.selected_alpha + Rectangle: + pos: self.x, self.y + 1 + size: self.size + + canvas.after: + Color: + rgba: 17 / 255, 32 / 255, 148 / 255, self.selected_alpha + Line: + width: 2 + rectangle: self.x, self.y, self.width, self.height + + ## 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..c5cc912 --- /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.bsfs.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.bsfs.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..05c651e --- /dev/null +++ b/tagit/actions/grouping.py @@ -0,0 +1,262 @@ +""" + +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.bsfs.Group, 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.bsfs.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..b7d0a87 --- /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.igsor.net/projects/tagit/' + +# 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/planes.kv b/tagit/actions/planes.kv new file mode 100644 index 0000000..184f949 --- /dev/null +++ b/tagit/actions/planes.kv @@ -0,0 +1,15 @@ +#:import resource_find kivy.resources.resource_find + +<ShowDashboard>: + source: resource_find('atlas://planes/dashboard') + tooltip: 'Switch to the Dashboard' + +<ShowBrowsing>: + source: resource_find('atlas://planes/browsing') + tooltip: 'Switch to the browsing plane' + +<ShowCodash>: + source: resource_find('atlas://planes/codash') + tooltip: 'Switch to the contextual dashboard' + +## EOF ## diff --git a/tagit/actions/planes.py b/tagit/actions/planes.py new file mode 100644 index 0000000..89f93bb --- /dev/null +++ b/tagit/actions/planes.py @@ -0,0 +1,57 @@ +""" + +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 + +# inner-module imports +from .action import Action + +# exports +__all__ = [] + + +## code ## + +# load kv +Builder.load_file(os.path.join(os.path.dirname(__file__), 'planes.kv')) + +# classes + +class ShowDashboard(Action): + """Switch to the dashboard.""" + text = kp.StringProperty('Dashboard') + + def apply(self): + planes = self.root.planes + if planes.current_slide != planes.dashboard: + planes.load_slide(planes.dashboard) + + +class ShowBrowsing(Action): + """Switch to the browsing plane.""" + text = kp.StringProperty('Browsing') + + def apply(self): + planes = self.root.planes + if planes.current_slide != planes.browsing: + planes.load_slide(planes.browsing) + + +class ShowCodash(Action): + """Switch to the contextual dashboard.""" + text = kp.StringProperty('Context') + + def apply(self): + planes = self.root.planes + if planes.current_slide != planes.codash: + planes.load_slide(planes.codash) + +## 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..ca36a51 --- /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.get(ns.bsfs.File, 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..8a20702 --- /dev/null +++ b/tagit/actions/tagging.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 +from functools import reduce, partial +import operator +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 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.bsfs.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.bsfs.Tag, TAG_PREFIX[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) # 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.bsfs.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) + # 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.bsfs.Tag, TAG_PREFIX[lbl]) + tag.set(ns.bst.label, lbl) + added.add(tag) + added = reduce(operator.add, added) + # 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 ## |