diff options
author | Matthias Baumgartner <dev@igsor.net> | 2023-01-06 14:07:15 +0100 |
---|---|---|
committer | Matthias Baumgartner <dev@igsor.net> | 2023-01-06 14:07:15 +0100 |
commit | ad49aedaad3acece200ea92fd5d5a5b3e19c143b (patch) | |
tree | 3f6833aa6f7a81f456e992cb7ea453cdcdf6c22e | |
parent | 079b4da93ea336b5bcc801cfd64c310aa7f8ddee (diff) | |
download | tagit-ad49aedaad3acece200ea92fd5d5a5b3e19c143b.tar.gz tagit-ad49aedaad3acece200ea92fd5d5a5b3e19c143b.tar.bz2 tagit-ad49aedaad3acece200ea92fd5d5a5b3e19c143b.zip |
desktop dependent widgets early port
50 files changed, 4922 insertions, 21 deletions
@@ -28,6 +28,8 @@ doc/build/ # test data binaries (keep the builder scripts only) # external builds +tagit/external/setproperty/setproperty.c +tagit/external/setproperty/setproperty.cpython* # assets diff --git a/tagit/actions/__init__.py b/tagit/actions/__init__.py new file mode 100644 index 0000000..24524b4 --- /dev/null +++ b/tagit/actions/__init__.py @@ -0,0 +1,131 @@ +""" + +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 library +#from . import misc +#from . import objects +#from . import planes +#from . import search +#from . import session +#from . import tabs + +# 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, + ## library + #'AutoUpdate': library.AutoUpdate, + #'UpdateSelectedObjects': library.UpdateSelectedObjects, + #'UpdateObjects': library.UpdateObjects, + #'AutoImport': library.AutoImport, + #'ImportObjects': library.ImportObjects, + #'AutoSync': library.AutoSync, + #'SyncSelectedObjects': library.SyncSelectedObjects, + #'SyncObjects': library.SyncObjects, + #'ItemExport': library.ItemExport, + ## 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': objects.AddTag, + #'EditTag': objects.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, + #'SaveSession': session.SaveSession, + #'SaveSessionAs': session.SaveSessionAs, + #'CreateSession': session.CreateSession, + #'CreateTempSession': session.CreateTempSession, + #'ReloadSession': session.ReloadSession, + #'CloseSessionAndExit': session.CloseSessionAndExit, + ## tabs + #'AddTab': tabs.AddTab, + #'CloseTab': tabs.CloseTab, + #'SwitchTab': tabs.SwitchTab, + } + +## 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..3702879 --- /dev/null +++ b/tagit/actions/filter.py @@ -0,0 +1,317 @@ +""" + +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 +from tagit import dialogues +#from tagit.parsing import ParserError # FIXME: mb/port +#from tagit.parsing.search import ast_from_string, ast_to_string, ast # FIXME: mb/port +#from tagit.storage.base import ns # FIXME: mb/port +from tagit.utils import Frame +from tagit.widgets.bindings 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(ast_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 = {node.label for node in self.root.session.storage.all(ns.tagit.storage.base.Tag)} + 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.ASTNode): + self.add_token([token]) + + def add_from_string(self, text): + try: + self.add_token(ast_from_string(text)) + except 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 = {node.label for node in self.root.session.storage.all(ns.tagit.storage.base.Tag)} + text = ast_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 = ast_from_string(obj.text) + except 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..eddaeb6 --- /dev/null +++ b/tagit/actions/grouping.py @@ -0,0 +1,257 @@ +""" + +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.parsing.search import ast # FIXME: mb/port +#from tagit.storage.broker import Representative # FIXME: mb/port +from tagit.widgets import Binding +from tagit.utils import Frame + +# inner-module imports +from .action import Action + +# 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 = Group.Create() + if label is not None: + grp.label = label + + # add items to group + ents = self.root.session.storage.entities(browser.unfold(browser.selection)) + ents.group += grp + + # select a random representative + grp.represented_by = random.choice(ents) + + # set selection and cursor to representative + # the representative will become valid after the search was re-applied + browser.selection.clear() + browser.selection.add(grp.represented_by) + browser.cursor = rep + + # notification + logger.info(f'Grouped {len(items)} items') + + # change event + session.dispatch('on_predicate_modified', 'group', items, {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: + # remove tag from items + items = list(cursor.members()) + #ents = ... + #grp = ... + #ents.group -= grp + for obj in items: + obj.group -= [cursor.represents()] # FIXME + + # 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(items)} items') + + # change event + session.dispatch('on_predicate_modified', 'group', items, {cursor.represents()}) + + 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: + items = browser.unfold(browser.selection) + for obj in items: + if obj == cursor: + # don't add group to itself + continue + obj.group += [cursor.represents()] # FIXME: Not quite sure how to handle this + + # 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', 'group', items, {cursor.represents()}) + + +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 + if cursor is not None and cursor in self.root.browser.folds: + token = ast.Token('group', ast.SetInclude(cursor.represents())) + self.root.trigger('AddToken', token) + + +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): + 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): + 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/dialogues/__init__.py b/tagit/dialogues/__init__.py new file mode 100644 index 0000000..bee5bf4 --- /dev/null +++ b/tagit/dialogues/__init__.py @@ -0,0 +1,59 @@ +"""Popup dialogues. + +A dialogue can be opened from the main application. +It appears on top of the application and prevent its use until the dialogue +is closed. A dialogue contains buttons whose presses can be captured. + +>>> dlg = LabelDialogue(text='Hello world') +>>> dlg.bind(on_ok=...) +>>> dlg.bind(on_cancel=...) +>>> dlg.open() + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 +""" +# standard imports +import typing + +# inner-module imports +##from .spash import Splash +#from .autoinput import AutoTextInput +#from .console import Console +#from .dir_creator import DirCreator +#from .dir_picker import DirPicker +#from .error import Error +#from .export import Export +#from .file_creator import FileCreator +#from .file_picker import FilePicker +#from .message import Message +#from .numeric_input import NumericInput +#from .path_creator import PathCreator +#from .path_picker import PathPicker +#from .progress import Progress +#from .project import Project +#from .simple_input import SimpleInput +#from .stoken import TokenEdit +#from .yesno import YesNo + +# exports +__all__: typing.Sequence[str] = ( + #'Console', + #'DirCreator', + #'DirPicker', + #'Error', + #'Export', + #'FileCreator', + #'FilePicker', + #'Message', + #'NumericInput', + #'PathCreator', + #'PathPicker', + #'Progress', + #'Project', + #'SimpleInput', + #'TokenEdit', + #'YesNo', + ) + +## EOF ## diff --git a/tagit/external/__init__.py b/tagit/external/__init__.py new file mode 100644 index 0000000..b973c86 --- /dev/null +++ b/tagit/external/__init__.py @@ -0,0 +1,15 @@ +""" + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 +""" +# standard imports +import typing + +# constants + +# exports +__all__: typing.Sequence[str] = [] + +## EOF ## diff --git a/tagit/external/kivy_garden/__init__.py b/tagit/external/kivy_garden/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tagit/external/kivy_garden/__init__.py diff --git a/tagit/external/kivy_garden/contextmenu/__init__.py b/tagit/external/kivy_garden/contextmenu/__init__.py new file mode 100644 index 0000000..ac55bff --- /dev/null +++ b/tagit/external/kivy_garden/contextmenu/__init__.py @@ -0,0 +1,11 @@ +from .context_menu import ContextMenu, \ + AbstractMenu, \ + AbstractMenuItem, \ + AbstractMenuItemHoverable, \ + ContextMenuItem, \ + ContextMenuDivider, \ + ContextMenuText, \ + ContextMenuTextItem + +from .app_menu import AppMenu, \ + AppMenuTextItem
\ No newline at end of file diff --git a/tagit/external/kivy_garden/contextmenu/_version.py b/tagit/external/kivy_garden/contextmenu/_version.py new file mode 100644 index 0000000..3ce5ddd --- /dev/null +++ b/tagit/external/kivy_garden/contextmenu/_version.py @@ -0,0 +1 @@ +__version__ = '0.1.0.dev1' diff --git a/tagit/external/kivy_garden/contextmenu/app_menu.kv b/tagit/external/kivy_garden/contextmenu/app_menu.kv new file mode 100644 index 0000000..644c6e5 --- /dev/null +++ b/tagit/external/kivy_garden/contextmenu/app_menu.kv @@ -0,0 +1,25 @@ +<AppMenu>: + height: dp(30) + size_hint: 1, None + + canvas.before: + Color: + rgb: 0.2, 0.2, 0.2 + Rectangle: + pos: self.pos + size: self.size + + +<AppMenuTextItem>: + disabled: True + size_hint: None, None + on_children: self._check_submenu() + font_size: '15sp' + background_normal: "" + background_down: "" + background_color: root.hl_color if self.state == 'down' else (0.2, 0.2, 0.2, 1.0) + background_disabled_normal: "" + background_disabled_down: "" + border: (0, 0, 0, 0) + size: self.texture_size[0], dp(30) + padding_x: dp(10) diff --git a/tagit/external/kivy_garden/contextmenu/app_menu.py b/tagit/external/kivy_garden/contextmenu/app_menu.py new file mode 100644 index 0000000..5394ec0 --- /dev/null +++ b/tagit/external/kivy_garden/contextmenu/app_menu.py @@ -0,0 +1,118 @@ +from kivy.uix.relativelayout import RelativeLayout +from kivy.uix.stacklayout import StackLayout +from kivy.uix.behaviors import ToggleButtonBehavior +from kivy.uix.togglebutton import ToggleButton +from kivy.lang import Builder +import kivy.properties as kp +import os + +from .context_menu import AbstractMenu, AbstractMenuItem, AbstractMenuItemHoverable, HIGHLIGHT_COLOR + + +class AppMenu(StackLayout, AbstractMenu): + bounding_box = kp.ObjectProperty(None) + + def __init__(self, *args, **kwargs): + super(AppMenu, self).__init__(*args, **kwargs) + self.hovered_menu_item = None + + def update_height(self): + max_height = 0 + for widget in self.menu_item_widgets: + if widget.height > max_height: + max_height = widget.height + return max_height + + def on_children(self, obj, new_children): + for w in new_children: + # bind events that update app menu height when any of its children resize + w.bind(on_size=self.update_height) + w.bind(on_height=self.update_height) + + def get_context_menu_root_parent(self): + return self + + def self_or_submenu_collide_with_point(self, x, y): + collide_widget = None + + # Iterate all siblings and all children + for widget in self.menu_item_widgets: + widget_pos = widget.to_window(0, 0) + if widget.collide_point(x - widget_pos[0], y - widget_pos[1]) and not widget.disabled: + if self.hovered_menu_item is None: + self.hovered_menu_item = widget + + if self.hovered_menu_item != widget: + self.hovered_menu_item = widget + for sibling in widget.siblings: + sibling.state = 'normal' + + if widget.state == 'normal': + widget.state = 'down' + widget.on_release() + + for sib in widget.siblings: + sib.hovered = False + elif widget.get_submenu() is not None and not widget.get_submenu().visible: + widget.state = 'normal' + + return collide_widget + + def close_all(self): + for submenu in [w.get_submenu() for w in self.menu_item_widgets if w.get_submenu() is not None]: + submenu.hide() + for w in self.menu_item_widgets: + w.state = 'normal' + + def hide_app_menus(self, obj, pos): + if not self.collide_point(pos.x, pos.y): + for w in [w for w in self.menu_item_widgets if not w.disabled and w.get_submenu().visible]: + submenu = w.get_submenu() + if submenu.self_or_submenu_collide_with_point(pos.x, pos.y) is None: + self.close_all() + self._cancel_hover_timer() + + +class AppMenuTextItem(ToggleButton, AbstractMenuItem): + label = kp.ObjectProperty(None) + text = kp.StringProperty('') + font_size = kp.NumericProperty(14) + color = kp.ListProperty([1, 1, 1, 1]) + hl_color = kp.ListProperty(HIGHLIGHT_COLOR) + + def on_release(self): + submenu = self.get_submenu() + + if self.state == 'down': + root = self._root_parent + submenu.bounding_box_widget = root.bounding_box if root.bounding_box else root.parent + + submenu.bind(visible=self.on_visible) + submenu.show(self.x, self.y - 1) + + for sibling in self.siblings: + if sibling.get_submenu() is not None: + sibling.state = 'normal' + sibling.get_submenu().hide() + + self.parent._setup_hover_timer() + else: + self.parent._cancel_hover_timer() + submenu.hide() + + def on_visible(self, *args): + submenu = self.get_submenu() + if self.width > submenu.get_max_width(): + submenu.width = self.width + + def _check_submenu(self): + super(AppMenuTextItem, self)._check_submenu() + self.disabled = (self.get_submenu() is None) + + # def on_mouse_down(self): + # print('on_mouse_down') + # return True + + +_path = os.path.dirname(os.path.realpath(__file__)) +Builder.load_file(os.path.join(_path, 'app_menu.kv')) diff --git a/tagit/external/kivy_garden/contextmenu/context_menu.kv b/tagit/external/kivy_garden/contextmenu/context_menu.kv new file mode 100644 index 0000000..c3f7133 --- /dev/null +++ b/tagit/external/kivy_garden/contextmenu/context_menu.kv @@ -0,0 +1,125 @@ +<ContextMenu>: + cols: 1 + size_hint: None, None + spacing: 0, 0 + spacer: _spacer + on_visible: self._on_visible(args[1]) + on_parent: self._on_visible(self.visible) + + Widget: + id: _spacer + size_hint: 1, None + height: dp(3) + canvas.before: + Color: + rgb: root.hl_color + Rectangle: + pos: self.pos + size: self.size + + +<ContextMenuItem>: + size_hint: None, None + submenu_arrow: _submenu_arrow + on_children: self._check_submenu() + on_parent: self._check_submenu() + canvas.before: + Color: + rgb: (0.15, 0.15, 0.15) + Rectangle: + pos: 0,0 + size: self.size + + Widget: + id: _submenu_arrow + size_hint: None, None + width: dp(6) + height: dp(11) + pos: self.parent.width - self.width - dp(5), (self.parent.height - self.height) / 2 + canvas.before: + Translate: + xy: self.pos + Color: + rgb: (0.35, 0.35, 0.35) if self.disabled else (1, 1, 1) + Triangle: + points: [0,0, self.width,self.height/2, 0,self.height] + Translate: + xy: (-self.pos[0], -self.pos[1]) + + +<ContextMenuText>: + label: _label + width: self.parent.width if self.parent else 0 + height: dp(26) + font_size: '15sp' + + Label: + pos: 0,0 + id: _label + text: self.parent.text + color: self.parent.color + font_size: self.parent.font_size + padding: dp(10), 0 + halign: 'left' + valign: 'middle' + size: self.texture_size + size_hint: None, 1 + + +<AbstractMenuItemHoverable>: + on_hovered: self._on_hovered(args[1]) + canvas.before: + Color: + rgb: (0.25, 0.25, 0.25) if self.hovered and not self.disabled else (0.15, 0.15, 0.15) + Rectangle: + pos: 0,0 + size: self.size + + +<ContextMenuDivider>: + font_size: '10sp' + height: dp(20) if len(self.label.text) > 0 else dp(1) + canvas.before: + Color: + rgb: (0.25, 0.25, 0.25) + Rectangle: + pos: 0,self.height - 1 + size: self.width, 1 + + +<ContextMenuButton@Button>: + size_hint: None, None + font_size: '12sp' + height: dp(20) + background_normal: "" + background_down: "" + background_color: HIGHLIGHT_COLOR + border: (0, 0, 0, 0) + on_press: self.background_color = 0.10, 0.6, 0.8, 1.0 + on_release: self.background_color = HIGHLIGHT_COLOR + + +<ContextMenuToggleButton@ToggleButton>: + size_hint: None, None + font_size: '12sp' + size: dp(30), dp(20) + background_normal: "" + background_down: "" + background_color: HIGHLIGHT_COLOR if self.state == 'down' else (0.25, 0.25, 0.25, 1.0) + border: (0, 0, 0, 0) + on_press: self.background_color = 0.10, 0.6, 0.8, 1.0 + on_release: self.background_color = HIGHLIGHT_COLOR + + +<ContextMenuSmallLabel@Label>: + size: self.texture_size[0], dp(18) + size_hint: None, None + font_size: '12sp' + + +<ContextMenuTextInput@TextInput>: + size_hint: None, None + height: dp(22) + font_size: '12sp' + padding: dp(7), dp(3) + multiline: False diff --git a/tagit/external/kivy_garden/contextmenu/context_menu.py b/tagit/external/kivy_garden/contextmenu/context_menu.py new file mode 100644 index 0000000..1613756 --- /dev/null +++ b/tagit/external/kivy_garden/contextmenu/context_menu.py @@ -0,0 +1,287 @@ +from kivy.uix.gridlayout import GridLayout +from kivy.uix.relativelayout import RelativeLayout +from kivy.uix.behaviors import ButtonBehavior +from kivy.lang import Builder +from kivy.clock import Clock +from functools import partial + +import kivy.properties as kp +import os + + +HIGHLIGHT_COLOR = [0.2, 0.71, 0.9, 1] + + +class AbstractMenu(object): + cancel_handler_widget = kp.ObjectProperty(None) + bounding_box_widget = kp.ObjectProperty(None) + + def __init__(self, *args, **kwargs): + self.clock_event = None + + def add_item(self, widget): + self.add_widget(widget) + + def add_text_item(self, text, on_release=None): + item = ContextMenuTextItem(text=text) + if on_release: + item.bind(on_release=on_release) + self.add_item(item) + + def get_height(self): + height = 0 + for widget in self.children: + height += widget.height + return height + + def hide_submenus(self): + for widget in self.menu_item_widgets: + widget.hovered = False + widget.hide_submenu() + + def self_or_submenu_collide_with_point(self, x, y): + raise NotImplementedError() + + def on_cancel_handler_widget(self, obj, widget): + self.cancel_handler_widget.bind(on_touch_down=self.hide_app_menus) + + def hide_app_menus(self, obj, pos): + raise NotImplementedError() + + @property + def menu_item_widgets(self): + """ + Return all children that are subclasses of ContextMenuItem + """ + return [w for w in self.children if issubclass(w.__class__, AbstractMenuItem)] + + def _setup_hover_timer(self): + if self.clock_event is None: + self.clock_event = Clock.schedule_interval(partial(self._check_mouse_hover), 0.05) + + def _check_mouse_hover(self, obj): + from kivy.core.window import Window + self.self_or_submenu_collide_with_point(*Window.mouse_pos) + + def _cancel_hover_timer(self): + if self.clock_event: + self.clock_event.cancel() + self.clock_event = None + + +class ContextMenu(GridLayout, AbstractMenu): + visible = kp.BooleanProperty(False) + spacer = kp.ObjectProperty(None) + hl_color = kp.ListProperty(HIGHLIGHT_COLOR) + + + def __init__(self, *args, **kwargs): + super(ContextMenu, self).__init__(*args, **kwargs) + self.orig_parent = None + # self._on_visible(False) + + def hide(self): + self.visible = False + + def show(self, x=None, y=None): + self.visible = True + self._add_to_parent() + self.hide_submenus() + + root_parent = self.bounding_box_widget if self.bounding_box_widget is not None else self.get_context_menu_root_parent() + if root_parent is None: + return + + point_relative_to_root = root_parent.to_local(*self.to_window(x, y)) + + # Choose the best position to open the menu + if x is not None and y is not None: + if point_relative_to_root[0] + self.width < root_parent.width: + pos_x = x + else: + pos_x = x - self.width + if issubclass(self.parent.__class__, AbstractMenuItem): + pos_x -= self.parent.width + + if point_relative_to_root[1] - self.height < 0: + pos_y = y + if issubclass(self.parent.__class__, AbstractMenuItem): + pos_y -= self.parent.height + self.spacer.height + else: + pos_y = y - self.height + + self.pos = pos_x, pos_y + + def self_or_submenu_collide_with_point(self, x, y): + queue = self.menu_item_widgets + collide_widget = None + + # Iterate all siblings and all children + while len(queue) > 0: + widget = queue.pop(0) + submenu = widget.get_submenu() + if submenu is not None and widget.hovered: + queue += submenu.menu_item_widgets + + widget_pos = widget.to_window(0, 0) + if widget.collide_point(x - widget_pos[0], y - widget_pos[1]) and not widget.disabled: + widget.hovered = True + + collide_widget = widget + for sib in widget.siblings: + sib.hovered = False + elif submenu and submenu.visible: + widget.hovered = True + else: + widget.hovered = False + + return collide_widget + + def _on_visible(self, new_visibility): + if new_visibility: + self.size = self.get_max_width(), self.get_height() + self._add_to_parent() + # @todo: Do we need to remove self from self.parent.__context_menus? Probably not. + + elif self.parent and not new_visibility: + self.orig_parent = self.parent + + ''' + We create a set that holds references to all context menus in the parent widget. + It's necessary to keep at least one reference to this context menu. Otherwise when + removed from parent it might get de-allocated by GC. + ''' + if not hasattr(self.parent, '_ContextMenu__context_menus'): + self.parent.__context_menus = set() + self.parent.__context_menus.add(self) + + self.parent.remove_widget(self) + self.hide_submenus() + self._cancel_hover_timer() + + def _add_to_parent(self): + if not self.parent: + self.orig_parent.add_widget(self) + self.orig_parent = None + + # Create the timer on the outer most menu object + if self._get_root_context_menu() == self: + self._setup_hover_timer() + + def get_max_width(self): + max_width = 0 + for widget in self.menu_item_widgets: + width = widget.content_width if widget.content_width is not None else widget.width + if width is not None and width > max_width: + max_width = width + + return max_width + + def get_context_menu_root_parent(self): + """ + Return the bounding box widget for positioning sub menus. By default it's root context menu's parent. + """ + if self.bounding_box_widget is not None: + return self.bounding_box_widget + root_context_menu = self._get_root_context_menu() + return root_context_menu.bounding_box_widget if root_context_menu.bounding_box_widget else root_context_menu.parent + + def _get_root_context_menu(self): + """ + Return the outer most context menu object + """ + root = self + while issubclass(root.parent.__class__, ContextMenuItem) \ + or issubclass(root.parent.__class__, ContextMenu): + root = root.parent + return root + + def hide_app_menus(self, obj, pos): + return self.self_or_submenu_collide_with_point(pos.x, pos.y) is None and self.hide() + + +class AbstractMenuItem(object): + submenu = kp.ObjectProperty(None) + + def get_submenu(self): + return self.submenu if self.submenu != "" else None + + def show_submenu(self, x=None, y=None): + if self.get_submenu(): + self.get_submenu().show(*self._root_parent.to_local(x, y)) + + def hide_submenu(self): + submenu = self.get_submenu() + if submenu: + submenu.visible = False + submenu.hide_submenus() + + def _check_submenu(self): + if self.parent is not None and len(self.children) > 0: + submenus = [w for w in self.children if issubclass(w.__class__, ContextMenu)] + if len(submenus) > 1: + raise Exception('Menu item (ContextMenuItem) can have maximum one submenu (ContextMenu)') + elif len(submenus) == 1: + self.submenu = submenus[0] + + @property + def siblings(self): + return [w for w in self.parent.children if issubclass(w.__class__, AbstractMenuItem) and w != self] + + @property + def content_width(self): + return None + + @property + def _root_parent(self): + return self.parent.get_context_menu_root_parent() + + +class ContextMenuItem(RelativeLayout, AbstractMenuItem): + submenu_arrow = kp.ObjectProperty(None) + + def _check_submenu(self): + super(ContextMenuItem, self)._check_submenu() + if self.get_submenu() is None: + self.submenu_arrow.opacity = 0 + else: + self.submenu_arrow.opacity = 1 + + +class AbstractMenuItemHoverable(object): + hovered = kp.BooleanProperty(False) + + def _on_hovered(self, new_hovered): + if new_hovered: + spacer_height = self.parent.spacer.height if self.parent.spacer else 0 + self.show_submenu(self.width, self.height + spacer_height) + else: + self.hide_submenu() + + +class ContextMenuText(ContextMenuItem): + label = kp.ObjectProperty(None) + submenu_postfix = kp.StringProperty(' ...') + text = kp.StringProperty('') + font_size = kp.NumericProperty(14) + color = kp.ListProperty([1,1,1,1]) + + def __init__(self, *args, **kwargs): + super(ContextMenuText, self).__init__(*args, **kwargs) + + @property + def content_width(self): + # keep little space for eventual arrow for submenus + return self.label.texture_size[0] + 10 + + +class ContextMenuDivider(ContextMenuText): + pass + + +class ContextMenuTextItem(ButtonBehavior, ContextMenuText, AbstractMenuItemHoverable): + pass + + +_path = os.path.dirname(os.path.realpath(__file__)) +Builder.load_file(os.path.join(_path, 'context_menu.kv')) diff --git a/tagit/external/setproperty/README.md b/tagit/external/setproperty/README.md new file mode 100644 index 0000000..e579132 --- /dev/null +++ b/tagit/external/setproperty/README.md @@ -0,0 +1,5 @@ + +build with + +$ python setup.py build_ext --inplace + diff --git a/tagit/external/setproperty/__init__.py b/tagit/external/setproperty/__init__.py new file mode 100644 index 0000000..b8fe9c2 --- /dev/null +++ b/tagit/external/setproperty/__init__.py @@ -0,0 +1,3 @@ + +from .setproperty import SetProperty + diff --git a/tagit/external/setproperty/setproperty.pxd b/tagit/external/setproperty/setproperty.pxd new file mode 100644 index 0000000..51acb25 --- /dev/null +++ b/tagit/external/setproperty/setproperty.pxd @@ -0,0 +1,9 @@ + +from kivy.properties cimport Property, PropertyStorage +from kivy._event cimport EventDispatcher, EventObservers + + + +cdef class SetProperty(Property): + pass + diff --git a/tagit/external/setproperty/setproperty.pyx b/tagit/external/setproperty/setproperty.pyx new file mode 100644 index 0000000..21bacbb --- /dev/null +++ b/tagit/external/setproperty/setproperty.pyx @@ -0,0 +1,125 @@ + +from weakref import ref + +cdef inline void observable_set_dispatch(object self) except *: + cdef Property prop = self.prop + obj = self.obj() + if obj is not None: + prop.dispatch(obj) + + +class ObservableSet(set): + # Internal class to observe changes inside a native python set. + def __init__(self, *largs): + self.prop = largs[0] + self.obj = ref(largs[1]) + super(ObservableSet, self).__init__(*largs[2:]) + + def __iand__(self, *largs): + set.__iand__(self, *largs) + observable_set_dispatch(self) + + def __ior__(self, *largs): + set.__ior__(self, *largs) + observable_set_dispatch(self) + + def __isub__(self, *largs): + set.__isub__(self, *largs) + observable_set_dispatch(self) + + def __ixor__(self, *largs): + set.__ixor__(self, *largs) + observable_set_dispatch(self) + + def add(self, *largs): + set.add(self, *largs) + observable_set_dispatch(self) + + def clear(self): + set.clear(self) + observable_set_dispatch(self) + + def difference_update(self, *largs): + set.difference_update(self, *largs) + observable_set_dispatch(self) + + def discard(self, *largs): + set.discard(self, *largs) + observable_set_dispatch(self) + + def intersection_update(self, *largs): + set.intersection_update(self, *largs) + observable_set_dispatch(self) + + def pop(self, *largs): + cdef object result = set.pop(self, *largs) + observable_set_dispatch(self) + return result + + def remove(self, *largs): + set.remove(self, *largs) + observable_set_dispatch(self) + + def symmetric_difference_update(self, *largs): + set.symmetric_difference_update(self, *largs) + observable_set_dispatch(self) + + def update(self, *largs): + set.update(self, *largs) + observable_set_dispatch(self) + + +cdef class SetProperty(Property): + '''Property that represents a set. + + :Parameters: + `defaultvalue`: set, defaults to set() + Specifies the default value of the property. + + .. warning:: + + When assigning a set to a :class:`SetProperty`, the set stored in + the property is a shallow copy of the set and not the original set. This can + be demonstrated with the following example:: + + >>> class MyWidget(Widget): + >>> my_set = SetProperty([]) + + >>> widget = MyWidget() + >>> my_set = {1, 5, {'hi': 'hello'}} + >>> widget.my_set = my_set + >>> print(my_set is widget.my_set) + False + >>> my_set.add(10) + >>> print(my_set, widget.my_set) + {1, 5, {'hi': 'hello'}, 10} {1, 5, {'hi': 'hello'}} + + However, changes to nested levels will affect the property as well, + since the property uses a shallow copy of my_set. + + ''' + def __init__(self, defaultvalue=0, **kw): + defaultvalue = set() if defaultvalue == 0 else defaultvalue + + super(SetProperty, self).__init__(defaultvalue, **kw) + + cpdef PropertyStorage link(self, EventDispatcher obj, str name): + Property.link(self, obj, name) + cdef PropertyStorage ps = obj.__storage[self._name] + if ps.value is not None: + ps.value = ObservableSet(self, obj, ps.value) + + cdef check(self, EventDispatcher obj, value, PropertyStorage property_storage): + if Property.check(self, obj, value, property_storage): + return True + if type(value) is not ObservableSet: + raise ValueError('%s.%s accept only ObservableSet' % ( + obj.__class__.__name__, + self.name)) + + cpdef set(self, EventDispatcher obj, value): + if value is not None: + value = ObservableSet(self, obj, value) + Property.set(self, obj, value) + + diff --git a/tagit/external/setproperty/setup.py b/tagit/external/setproperty/setup.py new file mode 100644 index 0000000..8500340 --- /dev/null +++ b/tagit/external/setproperty/setup.py @@ -0,0 +1,6 @@ +from distutils.core import Extension, setup +from Cython.Build import cythonize + +# define an extension that will be cythonized and compiled +ext = Extension(name="setproperty", sources=["setproperty.pyx"]) +setup(ext_modules=cythonize(ext)) diff --git a/tagit/external/setproperty/test.py b/tagit/external/setproperty/test.py new file mode 100644 index 0000000..e241786 --- /dev/null +++ b/tagit/external/setproperty/test.py @@ -0,0 +1,62 @@ +from kivy.app import App +from kivy.lang import Builder +from time import time +from kivy.uix.label import Label +from kivy.uix.boxlayout import BoxLayout +import kivy.properties as kp +from setproperty import SetProperty + +Builder.load_string(''' +<Foo>: + orientation: 'vertical' + text: '' + + BoxLayout: + orientation: 'horizontal' + + ToggleButton: + id: btn_add + group: 'action' + text: 'add' + state: 'down' + + ToggleButton: + group: 'action' + text: 'delete' + + TextInput + id: value + + Button: + on_press: root.update_dict(btn_add.state == 'down', value.text) + text: 'change set' + + Label: + id: dictout + text: root.text + +''') + + +class Foo(BoxLayout): + + text = kp.StringProperty() + my_set = SetProperty() + + def on_my_set(self, wx, my_set): + self.text = str(time()) + ' ' + str(my_set) + + def update_dict(self, add, value): + if add: + self.my_set.add(value) + else: + self.my_set.discard(value) + + +class TestApp(App): + def build(self): + return Foo() + +if __name__ == '__main__': + TestApp().run() + diff --git a/tagit/external/tooltip.kv b/tagit/external/tooltip.kv new file mode 100644 index 0000000..27c3ab7 --- /dev/null +++ b/tagit/external/tooltip.kv @@ -0,0 +1,12 @@ + +<Tooltip_Label>: + size_hint: None, None + size: self.texture_size[0]+5, self.texture_size[1]+5 + canvas.before: + Color: + rgb: 0.2, 0.2, 0.2 + Rectangle: + size: self.size + pos: self.pos + +## EOF ## diff --git a/tagit/external/tooltip.py b/tagit/external/tooltip.py new file mode 100644 index 0000000..2865206 --- /dev/null +++ b/tagit/external/tooltip.py @@ -0,0 +1,67 @@ +"""Tooltips. + +From: + http://stackoverflow.com/questions/34468909/how-to-make-tooltip-using-kivy + +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 +from kivy.uix.label import Label +from kivy.clock import Clock +# Cannot import kivy.core.window.Window here; Leads to a segfault. +# Doing it within the *Tooltip* class works just fine, though. + +# exports +__all__ = ('Tooltip', ) + + +## CODE ## + +Builder.load_file(os.path.join(os.path.dirname(__file__), 'tooltip.kv')) + +class Tooltip_Label(Label): + pass + +# FIXME: Tooltip makes the whole UI *way* too slow, hence it's body is disabled +class Tooltip(object): + def set_tooltip(self, text): + pass + +# if hasattr(self, '_tooltip_wx') and self._tooltip_wx is not None: +# self._tooltip_wx.text = text +# else: +# self._tooltip_wx = Tooltip_Label(text=text) +# from kivy.core.window import Window +# Window.bind(mouse_pos=self.on_mouse_pos) +# +# def on_mouse_pos(self, *args): +# if not self.get_root_window(): +# return +# +# pos_x, pos_y = pos = args[1] +# from kivy.core.window import Window +# pos_x = max(0, min(pos_x, Window.width - self._tooltip_wx.width)) +# pos_y = max(0, min(pos_y, Window.height - self._tooltip_wx.height)) +# self._tooltip_wx.pos = (pos_x, pos_y) +# +# Clock.unschedule(self.display_tooltip) # cancel scheduled event since I moved the cursor +# self.close_tooltip() # close if it's opened +# if self.collide_point(*pos): +# Clock.schedule_once(self.display_tooltip, 1) +# +# def close_tooltip(self, *args): +# from kivy.core.window import Window +# Window.remove_widget(self._tooltip_wx) +# +# def display_tooltip(self, *args): +# from kivy.core.window import Window +# Window.add_widget(self._tooltip_wx) + +## EOF ## diff --git a/tagit/tiles/__init__.py b/tagit/tiles/__init__.py new file mode 100644 index 0000000..3ed53b9 --- /dev/null +++ b/tagit/tiles/__init__.py @@ -0,0 +1,61 @@ +""" + +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 .anomalies import Anomalies # FIXME: skeleton only +#from .browser_tags import BrowserTags +#from .buttons import Buttons +#from .cursor_tags import CursorTags +#from .entity_histogram import EntityHistogram +#from .geo import Map +#from .hints import Hints +#from .info import Info +#from .libsummary import LibSummary +#from .searchtree import Searchtree +#from .selection_tags import SelectionTags +#from .suggested_tags import SuggestedTags +#from .tag_distribution import TagDistribution +#from .tag_histogram import TagHistogram +#from .tag_tree import TagTree +#from .tagcloud import Tagcloud +#from .venn import Venn + +# exports +__all__: typing.Sequence[str] = ( + 'TileBuilder', + ) + + +## code ## + +class TileBuilder(BuilderBase): + _factories = { +# #'Anomalies': Anomalies, +# 'BrowserTags': BrowserTags, +# 'Buttons': Buttons, +# 'CursorTags': CursorTags, +# 'EntityHistogram': EntityHistogram, +# 'Geo': Map, +# 'Hints': Hints, +# 'Info': Info, +# 'LibSummary': LibSummary, +# 'Searchtree': Searchtree, +# 'SelectionTags': SelectionTags, +# 'SuggestedTags': SuggestedTags, +# 'TagDistribution': TagDistribution, +# 'TagHistogram': TagHistogram, +# 'TagTree': TagTree, +# 'Tagcloud': Tagcloud, +# 'Venn': Venn, + } + +## EOF ## diff --git a/tagit/tiles/decoration.kv b/tagit/tiles/decoration.kv new file mode 100644 index 0000000..a53d013 --- /dev/null +++ b/tagit/tiles/decoration.kv @@ -0,0 +1,58 @@ + +# NOTE: +# TileDecoration assumes as *cbox* property that identifies the widget +# to which the main content will be added. + +<TileDecorationVanilla>: + cbox: cbox + + RelativeLayout: + id: cbox + +<TileDecorationBorder>: + cbox: cbox + + canvas.after: + # tile shadow + Color: + rgb: 0.2,0.2,0.2 + Line: + rectangle: self.x+5,self.y+5,self.width-10,self.height-10 + width: 2 + + Color: + rgb: 0.6,0.6,0.6 + Line: + rectangle: self.x+7,self.y+7,self.width-14,self.height-14 + width: 1 + + RelativeLayout: + id: cbox + pos: 15, 15 + size: root.width-30, root.height-30 + size_hint: None, None + +<TileDecorationFilledRectangle>: + cbox: cbox + + Label: + text: root.client.title + size: root.width, 20 + size_hint: None, None + pos: 0, root.height - self.height - 5 + + RelativeLayout: + id: cbox + pos: 5, 5 + size: root.width-10, root.height-30 + size_hint: None, None + + canvas.before: + Color: + rgba: 1,0,0,0.5 + Rectangle: + pos: 0, 0 + size: self.size + + +## EOF ## diff --git a/tagit/tiles/decoration.py b/tagit/tiles/decoration.py new file mode 100644 index 0000000..471058d --- /dev/null +++ b/tagit/tiles/decoration.py @@ -0,0 +1,68 @@ +""" + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 +""" +# standard imports +import os +import typing + +# kivy imports +from kivy.lang import Builder +from kivy.uix.relativelayout import RelativeLayout +import kivy.properties as kp + +# exports +__all__: typing.Sequence[str] = ( + 'TileDecorationBorder', + 'TileDecorationFilledRectangle', + 'TileDecorationVanilla', + ) + + +## code ## + +# load kv +Builder.load_file(os.path.join(os.path.dirname(__file__), 'decoration.kv')) + +# classes +class TileDecoration(RelativeLayout): + + cbox = kp.ObjectProperty(None) + client = kp.ObjectProperty(None) + + def __repr__(self): + return f'{self.__class__.__name__}({self.client})' + + def on_cbox(self, wx, cbox): + if cbox is not None and len(cbox.children) == 0: + cbox.add_widget(self.client) + + @property + def default_size(self): + return self.client.default_size + + +class TileDecorationVanilla(TileDecoration): + pass + + +class TileDecorationFilledRectangle(TileDecoration): + @property + def default_size(self): + width, height = self.client.default_size + width = None if width is None else width + 10 + height = None if height is None else height + 30 + return width, height + + +class TileDecorationBorder(TileDecoration): + @property + def default_size(self): + width, height = self.client.default_size + width = None if width is None else width + 30 + height = None if height is None else height + 30 + return width, height + +## EOF ## diff --git a/tagit/utils/__init__.py b/tagit/utils/__init__.py index d143034..3f09078 100644 --- a/tagit/utils/__init__.py +++ b/tagit/utils/__init__.py @@ -9,6 +9,8 @@ import typing # inner-module imports from . import bsfs +from . import time as ttime +from .frame import Frame from .shared import * # FIXME: port properly # exports diff --git a/tagit/utils/builder.py b/tagit/utils/builder.py new file mode 100644 index 0000000..f6c5818 --- /dev/null +++ b/tagit/utils/builder.py @@ -0,0 +1,82 @@ +""" + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 +""" +# standard imports +from collections import abc +from functools import partial +from inspect import isclass +import typing + +# exports +__all__: typing.Sequence[str] = ( + 'BuilderBase', + 'InvalidFactoryName', + ) + + +## code ## + +class InvalidFactoryName(KeyError): pass + +class BuilderBase(abc.Mapping, abc.Hashable): + _factories = dict() + + def __getitem__(self, key): + return self.get(key) + + def __contains__(self, key): + return key in self._factories + + def __iter__(self): + return iter(self._factories.keys()) + + def __hash__(self): + return hash(frozenset(self._factories.items())) + + def __len__(self): + return len(self._factories) + + def __eq__(self, other): + return type(self) == type(other) and self._factories == other._factories + + + def get(self, key): + if key not in self._factories: + raise InvalidFactoryName(key) + return self._factories[key] + + @classmethod + def keys(self): + return self._factories.keys() + + @classmethod + def items(self): + return self._factories.items() + + @classmethod + def describe(cls, key): + if key not in cls._factories: + raise InvalidFactoryName(key) + desc = cls._factories[key].__doc__ + return desc if desc is not None else '' + + def prepare(self, key, *args, **kwargs): + # If building is to be customized, overwrite this function. + return partial(self[key], *args, **kwargs) + + def build(self, key, *args, **kwargs): + fu = self.prepare(key, *args, **kwargs) + return fu() + + def key_from_instance(self, cls): + for key, clbk in self._factories.items(): + if isclass(clbk) and isinstance(cls, clbk): + return key + if not isclass(clbk) and cls == clbk: + return key + raise KeyError(type(cls)) + +## EOF ## diff --git a/tagit/utils/frame.py b/tagit/utils/frame.py new file mode 100644 index 0000000..c6bdc1e --- /dev/null +++ b/tagit/utils/frame.py @@ -0,0 +1,84 @@ +""" + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 +""" +# standard imports +import json + +# exports +__all__ = ('Frame', ) + + +## code ## + +class Frame(dict): + def __init__(self, cursor=None, selection=None, offset=0, **kwargs): + super(Frame, self).__init__(**kwargs) + selection = selection if selection is not None else [] + self['cursor'] = cursor + self['selection'] = selection + self['offset'] = offset + + @property + def selection(self): + return self['selection'] + + @property + def cursor(self): + return self['cursor'] + + @property + def offset(self): + return self['offset'] + + def copy(self): + return Frame(**super(Frame, self).copy()) + + def serialize(self): + return json.dumps({ + 'cursor': self.cursor.guid if self.cursor is not None else 'None', + 'group': self.cursor.group if hasattr(self.cursor, 'group') else 'None', + 'selection': [img.guid for img in self.selection], + 'offset': self.offset + }) + + @staticmethod + def from_serialized(lib, serialized, ignore_errors=True): + d = json.loads(serialized) + + # load cursor + cursor = None + try: + if 'cursor' in d and d['cursor'] is not None and d['cursor'].lower() != 'none': + cursor = lib.entity(d['cursor']) + except KeyError as err: + if not ignore_errors: + raise err + + if 'group' in d and d['group'] is not None and d['group'].lower() != 'none': + try: + # FIXME: late import; breaks module dependency structure + from tagit.storage.library.entity import Representative + cursor = Representative.Representative(lib, d['group']) + except ValueError: + # group doesn't exist anymore; ignore + pass + + # load selection + selection = [] + for guid in d.get('selection', []): + try: + selection.append(lib.entity(guid)) + except KeyError as err: + if not ignore_errors: + raise err + + return Frame( + cursor = cursor, + selection = selection, + offset = d.get('offset', 0) + ) + +## EOF ## diff --git a/tagit/utils/shared.py b/tagit/utils/shared.py index 13ffd2a..82fe672 100644 --- a/tagit/utils/shared.py +++ b/tagit/utils/shared.py @@ -6,22 +6,25 @@ A copy of the license is provided with the project. Author: Matthias Baumgartner, 2022 """ # standard imports +from collections import namedtuple import logging +import os import pkgutil import re import typing # exports -__all__ = ('import_all', ) - - -## code ## - -# exports __all__: typing.Sequence[str] = ( + 'Resolution', + 'Struct', + 'clamp', + 'flatten', 'fst', - 'is_list', 'import_all', + 'is_hex', + 'is_list', + 'magnitude_fmt', + 'truncate_dir', ) @@ -60,4 +63,64 @@ def import_all(module, exclude=None, verbose=False): return imports +def clamp(value, hi, lo=0): + """Restrain a *value* to the range *lo* to *hi*.""" + return max(lo, min(hi, value)) + +Resolution = namedtuple('resolution', ('width', 'height')) + +def truncate_dir(path, cutoff=3): + """Remove path up to last *cutoff* directories""" + if cutoff < 0: raise ValueError('path cutoff must be positive') + dirs = os.path.dirname(path).split(os.path.sep) + last_dirs = dirs[max(0, len(dirs) - cutoff):] + prefix = '' + if os.path.isabs(path) and len(last_dirs) == len(dirs): + prefix = os.path.sep + + return prefix + os.path.join(*(last_dirs + [os.path.basename(path)])) + +def magnitude_fmt(num, suffix='iB', scale=1024): + """Human-readable number format. + + adapted from Sridhar Ratnakumar, 2009 + https://stackoverflow.com/questions/1094841/reusable-library-to-get-human-readable-version-of-file-size + """ + for unit in ['','K','M','G','T','P','E','Z']: + if abs(num) < scale: + return "%3.1f%s%s" % (num, unit, suffix) + num /= scale + return "%.1f%s%s" % (num, 'Y', suffix) + +class Struct(dict): + """Dict with item access as members. + + >>> tup = Struct(timestamp=123, series=['1','2','3']) + >>> tup.timestamp + 123 + >>> tup['timestamp'] + 123 + + """ + def __getattr__(self, name): + return self[name] + def __setattr__(self, name, value): + self[name] = value + +def flatten(lst): + flat = [] + for itm in lst: + flat.extend(list(itm)) + return flat + +def is_hex(string): + """Return True if the *string* can be interpreted as a hex value.""" + try: + int(string, 16) + return True + except ValueError: + return False + except TypeError: + return False + ## EOF ## diff --git a/tagit/utils/time.py b/tagit/utils/time.py new file mode 100644 index 0000000..4260ac7 --- /dev/null +++ b/tagit/utils/time.py @@ -0,0 +1,63 @@ +"""Time helpers. + +* Camera local +* System local +* UTC + +Timestamp to datetime + * Timestamp + * in UTC + * Timezone + * Implicit system local timezone + * No known timezone + * Known timezone + +Datetime to timestamp + * always store as local time + * optionally with UTC offset + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 +""" +# standard imports +from datetime import timezone, datetime, tzinfo, timedelta + +# exports +__all__ = ('timestamp', 'from_timestamp') + + +## code ## + +timestamp_loc = lambda dt: dt.replace(tzinfo=timezone.utc).timestamp() + +timestamp_utc = lambda dt: dt.timestamp() + +from_timestamp_loc = lambda ts: datetime.utcfromtimestamp(ts) + +from_timestamp_utc = lambda ts: datetime.fromtimestamp(ts) + +now = datetime.now + +timestamp_min = timestamp_loc(datetime.min) + +timestamp_max = timestamp_loc(datetime.max) + +def utcoffset(dt): + if dt.tzinfo is None: + return local_tzo(dt) + elif dt.tzinfo is NoTimeZone: + return None + else: + return dt.tzinfo.utcoffset(dt).total_seconds() / 3600 + +NoTimeZone = timezone(timedelta(0), 'NoTimeZone') + +def local_tzo(dt=None): + """Return the offset between the local time and UTC. + (i.e. return the x of UTC+x). + """ + dt = datetime.now() if dt is None else dt + return (timestamp_loc(dt) - dt.timestamp()) / 3600 + +## EOF ## diff --git a/tagit/widgets/__init__.py b/tagit/widgets/__init__.py index c3ec3c0..3892a22 100644 --- a/tagit/widgets/__init__.py +++ b/tagit/widgets/__init__.py @@ -5,6 +5,7 @@ A copy of the license is provided with the project. Author: Matthias Baumgartner, 2022 """ # inner-module imports +from .bindings import Binding from .desktop import MainWindow ## EOF ## diff --git a/tagit/widgets/bindings.py b/tagit/widgets/bindings.py new file mode 100644 index 0000000..3192c4e --- /dev/null +++ b/tagit/widgets/bindings.py @@ -0,0 +1,278 @@ +"""Configurable keybindings. + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 +""" +# standard imports +import typing + +# tagit imports +from tagit.utils import errors + +# exports +__all__: typing.Sequence[str] = ( + 'Binding', + ) + + +## code ## + +class Binding(object): + """Handle keybindings. + + A keybinding is a set of three constraints: + * Key code + * Inclusive modifiers + * Exclusive modifiers + + Inclusive modifiers must be present, exclusive ones must not be present. + Modifiers occuring in neither of the two lists are ignored. + + Modifiers are always lowercase strings. Additionally to SHIFT, CTRL and ALT, + the modifiers "all" and "rest" can be used. + "all" is a shortcut for all of the modifiers known. + "rest" means all modifiers not consumed by the other list yet. "rest" can + therefore only occur in at most one of the lists. + + Usage example: + + >>> # From settings, with PGUP w/o modifiers as default + >>> Binding.check(evt, self.cfg("bindings", "browser", "page_prev", + ... default=Binding.simple(Binding.PGUP, None, Binding.mALL))) + + >>> # ESC or CTRL + SHIFT + a + >>> Binding.check(evt, Binding.multi((Binding.ESC, ), + ... (97, (Binding.mCTRL, Binding.mSHIFT), Binding.mREST)))) + + """ + + # Modifiers + mSHIFT = 'shift' + mCTRL = 'ctrl' + mALT = 'alt' + mCMD = 'cmd' + mALTGR = 'altgr' + mNUMLOCK = 'numlock' + mCAPSLOCK = 'capslock' + # Modifier specials + mALL = 'all' + mREST = 'rest' + # Special keys + BACKSPACE = 8 + TAB = 9 + ENTER = 13 + ESC = 27 + SPACEBAR = 32 + DEL = 127 + UP = 273 + DOWN = 274 + RIGHT = 275 + LEFT = 276 + INSERT = 277 + HOME = 278 + END = 279 + PGUP = 280 + PGDN = 281 + F1 = 282 + F2 = 283 + F3 = 284 + F4 = 285 + F5 = 286 + F6 = 287 + F7 = 288 + F8 = 289 + F9 = 290 + F10 = 291 + F11 = 292 + F12 = 293 + CAPSLOCK = 301 + RIGHT_SHIFT = 303 + LEFT_SHIFT = 304 + LEFT_CTRL = 305 + RIGHT_CTRL = 306 + ALTGR = 307 + ALT = 308 + CMD = 309 + + @staticmethod + def simple(code, inclusive=None, exclusive=None): + """Create a binding constraint.""" + # handle strings + inclusive = (inclusive, ) if isinstance(inclusive, str) else inclusive + exclusive = (exclusive, ) if isinstance(exclusive, str) else exclusive + + # handle None, ensure tuple + inclusive = tuple(inclusive) if inclusive is not None else tuple() + exclusive = tuple(exclusive) if exclusive is not None else tuple() + + # handle code + code = Binding.str_to_key(code.lower()) if isinstance(code, str) else code + if code is None: + raise errors.ProgrammingError('invalid key code') + + # build constraint + return [(code, inclusive, exclusive)] + + @staticmethod + def multi(*args): + """Return binding for multiple constraints.""" + return [Binding.simple(*arg)[0] for arg in args] + + @staticmethod + def from_string(string): + mods = (Binding.mSHIFT, Binding.mCTRL, Binding.mALT, Binding.mCMD, + Binding.mALTGR, Binding.mNUMLOCK, Binding.mCAPSLOCK) + + bindings = [] + for kcombo in (itm.strip() for itm in string.split(';')): + strokes = [key.lower().strip() for key in kcombo.split('+')] + + # modifiers; ignore lock modifiers + inc = [key for key in strokes if key in mods] + inc = [key for key in inc if key not in (Binding.mNUMLOCK, Binding.mCAPSLOCK)] + # key + code = [key for key in strokes if key not in mods] + if len(code) != 1: + raise errors.ProgrammingError('there must be exactly one key code in a keybinding') + code = Binding.str_to_key(code[0]) + if code is None: + raise errors.ProgrammingError('invalid key code') + + bindings.append((code, tuple(inc), (Binding.mREST, ))) + + return bindings + + @staticmethod + def to_string(constraints): + values = [] + for code, inc, exc in constraints: + values.append( + ' + '.join([m.upper() for m in inc] + [Binding.key_to_str(code)])) + return '; '.join(values) + + @staticmethod + def check(stroke, constraint): + """Return True if *evt* matches the *constraint*.""" + code, char, modifiers = stroke + all_ = {Binding.mSHIFT, Binding.mCTRL, Binding.mALT, Binding.mCMD, Binding.mALTGR} + for key, inclusive, exclusive in constraint: + inclusive, exclusive = set(inclusive), set(exclusive) + + if key in (code, char): # Otherwise, we don't have to process the modifiers + # Handle specials + if 'all' in inclusive: + inclusive = all_ + if 'all' in exclusive: + exclusive = all_ + if 'rest' in inclusive: + inclusive = all_ - exclusive + if 'rest' in exclusive: + exclusive = all_ - inclusive + + if (all([mod in modifiers for mod in inclusive]) and + all([mod not in modifiers for mod in exclusive])): + # Code and modifiers match + return True + + # No matching constraint found + return False + + @staticmethod + def key_to_str(code, default='?'): + if isinstance(code, str): + return code + + if 32 <= code and code <= 226 and code != 127: + return chr(code) + + return { + Binding.BACKSPACE : 'BACKSPACE', + Binding.TAB : 'TAB', + Binding.ENTER : 'ENTER', + Binding.ESC : 'ESC', + Binding.SPACEBAR : 'SPACEBAR', + Binding.DEL : 'DEL', + Binding.UP : 'UP', + Binding.DOWN : 'DOWN', + Binding.RIGHT : 'RIGHT', + Binding.LEFT : 'LEFT', + Binding.INSERT : 'INSERT', + Binding.HOME : 'HOME', + Binding.END : 'END', + Binding.PGUP : 'PGUP', + Binding.PGDN : 'PGDN', + Binding.F1 : 'F1', + Binding.F2 : 'F2', + Binding.F3 : 'F3', + Binding.F4 : 'F4', + Binding.F5 : 'F5', + Binding.F6 : 'F6', + Binding.F7 : 'F7', + Binding.F8 : 'F8', + Binding.F9 : 'F9', + Binding.F10 : 'F10', + Binding.F11 : 'F11', + Binding.F12 : 'F12', + Binding.CAPSLOCK : 'CAPSLOCK', + Binding.RIGHT_SHIFT : 'RIGHT_SHIFT', + Binding.LEFT_SHIFT : 'LEFT_SHIFT', + Binding.LEFT_CTRL : 'LEFT_CTRL', + Binding.RIGHT_CTRL : 'RIGHT_CTRL', + Binding.ALTGR : 'ALTGR', + Binding.ALT : 'ALT', + Binding.CMD : 'CMD', + }.get(code, default) + + @staticmethod + def str_to_key(char, default=None): + if isinstance(char, int): + return char + + try: + # check if ascii + code = ord(char) + if 32 <= code and code <= 226: + return code + except TypeError: + pass + + return { + 'BACKSPACE' : Binding.BACKSPACE, + 'TAB' : Binding.TAB, + 'ENTER' : Binding.ENTER, + 'ESC' : Binding.ESC, + 'SPACEBAR' : Binding.SPACEBAR, + 'DEL' : Binding.DEL, + 'UP' : Binding.UP, + 'DOWN' : Binding.DOWN, + 'RIGHT' : Binding.RIGHT, + 'LEFT' : Binding.LEFT, + 'INSERT' : Binding.INSERT, + 'HOME' : Binding.HOME, + 'END' : Binding.END, + 'PGUP' : Binding.PGUP, + 'PGDN' : Binding.PGDN, + 'F1' : Binding.F1, + 'F2' : Binding.F2, + 'F3' : Binding.F3, + 'F4' : Binding.F4, + 'F5' : Binding.F5, + 'F6' : Binding.F6, + 'F7' : Binding.F7, + 'F8' : Binding.F8, + 'F9' : Binding.F9, + 'F10' : Binding.F10, + 'F11' : Binding.F11, + 'F12' : Binding.F12, + 'CAPSLOCK' : Binding.CAPSLOCK, + 'RIGHT_SHIFT' : Binding.RIGHT_SHIFT, + 'LEFT_SHIFT' : Binding.LEFT_SHIFT, + 'LEFT_CTRL' : Binding.LEFT_CTRL, + 'RIGHT_CTRL' : Binding.RIGHT_CTRL, + 'ALTGR' : Binding.ALTGR, + 'ALT' : Binding.ALT, + 'CMD' : Binding.CMD, + }.get(char, default) + +## EOF ## diff --git a/tagit/widgets/browser.kv b/tagit/widgets/browser.kv new file mode 100644 index 0000000..ed40a44 --- /dev/null +++ b/tagit/widgets/browser.kv @@ -0,0 +1,100 @@ +#:import OpenGroup tagit.actions.grouping + +<Browser>: + root: None + spacing: 10 + size_hint: 1.0, 1.0 + page_size: self.cols * self.rows + # must not define rows and cols + +<BrowserItem>: + is_cursor: False + is_selected: False + + canvas.after: + Color: + rgba: 1,1,1, 1 if self.is_cursor else 0 + Line: + width: 2 + rectangle: self.x, self.y, self.width, self.height + + Color: + rgba: self.scolor + [0.5 if self.is_selected else 0] + Rectangle: + pos: self.x, self.center_y - int(self.height) / 2 + size: self.width, self.height + + +<BrowserImage>: # This be an image + preview: image + + AsyncBufferImage: + id: image + size_hint: None, None + # actual size is set in code + pos: 0, 0 + # coordinates of the (actual) image's top-right corner + tr_x: self.center_x + self.texture.width / 2.0 if self.texture is not None else None + tr_y: self.center_y + self.texture.height / 2.0 if self.texture is not None else None + + OpenGroup: + root: root.browser.root + # positioning: + # (1) top right corner of the root (inside root) + #x: root.width - self.width + #y: root.height - self.height + # (2) top right corner of the root (inside root) + #pos_hint: {'top': 1.0, 'right': 1.0} + # (3) top right corner of the image (outside the image) + #x: image.tx is not None and image.tx or float('inf') + #y: image.ty is not None and image.ty or float('inf') + # (4) top right corner of the image (inside root, outside the image if possible) + tr_x: root.width - self.width + tr_y: root.height - self.height + x: min(self.tr_x, image.tr_x is not None and image.tr_x or float('inf')) + y: min(self.tr_y, image.tr_y is not None and image.tr_y or float('inf')) + + opacity: root.is_group and 1.0 or 0.0 + show: 'image', + +<BrowserDescription>: # This be a list item + spacer: 20 + preview: image + + AsyncBufferImage: + id: image + size_hint: None, 1 + # actual size is set in code + pos: 0, 0 + + Label: + text: root.text + markup: True + halign: 'left' + valign: 'center' + text_size: self.size + size_hint: None, 1 + width: root.width - image.width - root.spacer - 35 + pos: root.height + root.spacer, 0 + +<AsyncBufferImage>: + mirror: False + angle: 0 + opacity: 0 + + canvas.before: + PushMatrix + Rotate: + angle: self.mirror and 180 or 0 + origin: self.center + axis: (0, 1, 0) + + Rotate: + angle: self.angle + origin: self.center + axis: (0, 0, 1) + + canvas.after: + PopMatrix + +## EOF ## diff --git a/tagit/widgets/browser.py b/tagit/widgets/browser.py new file mode 100644 index 0000000..df1a8b8 --- /dev/null +++ b/tagit/widgets/browser.py @@ -0,0 +1,677 @@ +""" + +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 logging +import math +import os +import typing + +# kivy imports +from kivy.clock import Clock +from kivy.core.image.img_pil import ImageLoaderPIL +from kivy.lang import Builder +from kivy.resources import resource_find +from kivy.uix.gridlayout import GridLayout +from kivy.uix.image import AsyncImage +from kivy.uix.relativelayout import RelativeLayout +import kivy.properties as kp + +# tagit imports +from tagit import config +from tagit.external.setproperty import SetProperty +#from tagit.storage import PredicateNotSet # FIXME: mb/port +#from tagit.storage.broker import Representative, Tags # FIXME: mb/port +from tagit.utils import Frame, Resolution, ttime, truncate_dir, clamp, magnitude_fmt + +# inner-module imports +from .loader import Loader +from .session import StorageAwareMixin, ConfigAwareMixin + +# exports +__all__: typing.Sequence[str] = ( + 'Browser', + ) + + +## code ## + +logger = logging.getLogger(__name__) + +# load kv +Builder.load_file(os.path.join(os.path.dirname(__file__), 'browser.kv')) + +# classes + +class ImageLoaderTagit(ImageLoaderPIL): + def load(self, filename): + data = super(ImageLoaderTagit, self).load(filename) + if len(data) > 1: + # source features multiple images + res = [(im.width, im.height) for im in data] + if len(set(res)) > 1: + # images have different resolutions; I'm guessing + # it's multiple previews embedded in the same image file. + # keep only the largest one. + idx = res.index(max(res, key=lambda wh: wh[0]*wh[1])) + data = [data[idx]] + + return data + +class ItemIndex(list): + """A list with constant time in index and contains operations. + List items must be hashable. Assumes the list is to be immutable. + Trades space for time by constructing an index and set at creation time. + """ + def __init__(self, items): + super(ItemIndex, self).__init__(items) + self._item_set = set(items) + self._index = {itm: idx for idx, itm in enumerate(items)} + + def index(self, item): + return self._index[item] + + def __contains__(self, value): + return value in self._item_set + + def as_set(self): + return self._item_set + +class Browser(GridLayout, StorageAwareMixin, ConfigAwareMixin): + """The browser displays a grid of item previews.""" + # root reference + root = kp.ObjectProperty(None) + + # select modes + SELECT_SINGLE = 0 + SELECT_MULTI = 1 + SELECT_RANGE = 2 + SELECT_ADDITIVE = 4 + SELECT_SUBTRACTIVE = 8 + # selection extras + range_base = [] + range_origin = None + # mode + select_mode = kp.NumericProperty(SELECT_SINGLE) + + # content + change_view = kp.BooleanProperty(False) + change_grid = kp.BooleanProperty(True) + items = kp.ObjectProperty(ItemIndex([])) + folds = kp.DictProperty() + + # frame + offset = kp.NumericProperty(0) + cursor = kp.ObjectProperty(None, allownone=True) + selection = SetProperty() + + # grid mode + GRIDMODE_GRID = 'grid' + GRIDMODE_LIST = 'list' + gridmode = kp.OptionProperty('grid', options=[GRIDMODE_GRID, GRIDMODE_LIST]) + # grid size + cols = kp.NumericProperty(3) + rows = kp.NumericProperty(3) + # page_size is defined in kivy such that it updates automatically + + # delayed view update event + _draw_view_evt = None + + ## initialization + + def on_root(self, wx, root): + StorageAwareMixin.on_root(self, wx, root) + ConfigAwareMixin.on_root(self, wx, root) + + def on_config_changed(self, session, key, value): + with self: + if key == ('ui', 'standalone', 'browser', 'cols'): + self.cols = max(1, value) + elif key == ('ui', 'standalone', 'browser', 'rows'): + self.rows = max(1, value) + elif key == ('ui', 'standalone', 'browser', 'gridmode'): + self.gridmode = value + elif key == ('ui', 'standalone', 'browser', 'fold_threshold'): + self.redraw() # FIXME: redraw doesn't exist + elif key == ('ui', 'standalone', 'browser', 'select_color'): + self.change_grid = True + + def on_cfg(self, wx, cfg): + with self: + self.cols = max(1, cfg('ui', 'standalone', 'browser', 'cols')) + self.rows = max(1, cfg('ui', 'standalone', 'browser', 'rows')) + self.gridmode = cfg('ui', 'standalone', 'browser', 'gridmode') + + def on_storage(self, wx, storage): + with self: + self.frame = Frame() + self.items = ItemIndex([]) + + + ## functions + + def set_items(self, items): + """Set the items. Should be used instead of setting items directly + to get the correct folding behaviour. + """ + items, folds = self.fold(items) + self.folds = folds + self.items = ItemIndex(items) + self.change_view = True + + def fold(self, items): + """Replace items in *items* if they are grouped. + Return the new item list and the dict of representatives. + """ + # get groups + stor = self.root.session.storage + groups, s_items = dict(), set(items) + # get groups[group_id] = {items which are also members of the group} + #stor.entities(items).grp() + for grp in Tags.From_Entities(stor, items, Tags.S_TREE): # FIXME! + groups[grp] = s_items & set(Representative.Representative(stor, grp).members()) + + # don't fold groups if few members + fold_threshold = self.root.session.cfg('ui', 'standalone', 'browser', 'fold_threshold') + groups = {grp: objs for grp, objs in groups.items() if len(objs) > fold_threshold} + # don't fold groups that make up all items + groups = {grp: objs for grp, objs in groups.items() if len(objs) < len(items)} + + def superset_exists(grp): + """Helper fu to detect subsets.""" + for objs in groups.values(): + if objs != groups[grp] and groups[grp].issubset(objs): + return True + return False + + # create folds + folds = { + Representative.Representative(self.root.session.storage, grp): objs + for grp, objs in groups.items() + if not superset_exists(grp) + } + + # add representatives + for rep in folds: + # add representative in place of the first of its members + idx = min([items.index(obj) for obj in folds[rep]]) + items.insert(idx, rep) + + # remove folded items + for obj in reduce(set.union, folds.values(), set()): + items.remove(obj) + + return items, folds + + def unfold(self, items): + """Replace group representatives by their group members.""" + unfolded = set() + for obj in items: + if obj in self.folds: + unfolded |= self.folds[obj] + else: + unfolded.add(obj) + + return unfolded + + def neighboring_unselected(self): + """Return the item closest to the cursor and not being selected. May return None.""" + if self.cursor in self.selection: + # set cursor to nearest neighbor + cur_idx = self.items.index(self.cursor) + sel_idx = {self.items.index(obj) for obj in self.selection} + + # find available items + n_right = {clamp(idx + 1, self.n_items - 1) for idx in sel_idx} + n_left = {clamp(idx - 1, self.n_items - 1) for idx in sel_idx} + cand = sorted((n_left | n_right) - sel_idx) + + # find closest to cursor + c_dist = [abs(idx - cur_idx) for idx in cand] + if len(c_dist) == 0: + return None + else: + # set cursor to item at candidate with minimum distance to cursor + return self.items[cand[c_dist.index(min(c_dist))]] + + else: + # cursor isn't selected + return self.cursor + + + ## properties + + @property + def frame(self): + return Frame(self.cursor, self.selection, self.offset) + + @frame.setter + def frame(self, frame): + self.offset = frame.offset + self.cursor = frame.cursor + self.selection = frame.selection + + @property + def n_items(self): + return len(self.items) + + @property + def max_offset(self): + return max(0, + self.n_items + (self.cols - (self.n_items % self.cols)) % self.cols - self.page_size) + + ## property listeners + + def on_cols(self, sender, cols): + #self.page_size = self.cols * self.rows + self.change_grid = True + + def on_rows(self, sender, rows): + #self.page_size = self.cols * self.rows + self.change_grid = True + + def on_offset(self, sender, offset): + self.change_view = True + + def on_cursor(self, sender, cursor): + if cursor is not None: + self.root.status.dispatch('on_status', truncate_dir(cursor.path)) + + def on_items(self, sender, items): + self.change_view = True + + # items might have changed; start caching + #if self.root.session.cfg('ui', 'standalone', 'browser', 'cache_all'): + # self._preload_all() + + def on_gridmode(self, sender, mode): + self.change_grid = True + + # resolution might have changed; start caching + #if self.root.session.cfg('ui', 'standalone', 'browser', 'cache_all'): + # self._preload_all() + + ## context + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + # ensure valid values for cursor, selection, and offset + # necessary if old frames were loaded while search filters have changed + if self.root.session.cfg('session', 'verbose') > 0: + # warn about changes + if self.cursor is not None and self.cursor not in self.items: + logger.warn(f'Fixing: cursor ({self.cursor})') + + if not self.selection.issubset(self.items.as_set()): + logger.warn('Fixing: selection') + if self.offset > self.max_offset or self.offset < 0: + logger.warn(f'Fixing: offset ({self.offset} not in [0, {self.max_offset}])') + + self.cursor = self.cursor if self.cursor in self.items else None + self.selection = self.items.as_set() & self.selection + self.offset = clamp(self.offset, self.max_offset) + + # issue redraw + if self.change_grid: + # grid change requires view change + self.draw_grid() + self.draw_view() + elif self.change_view: + timeout = self.root.session.cfg('ui', 'standalone', 'browser', 'page_delay') / 1000 + if timeout > 0: + self._draw_view_evt = Clock.schedule_once(lambda dt: self.draw_view(), timeout) + else: + self.draw_view() + + # reset flags + self.change_grid = False + self.change_view = False + + + def draw_grid(self): + if self.gridmode == self.GRIDMODE_LIST: + factory = BrowserDescription + elif self.gridmode == self.GRIDMODE_GRID: + factory = BrowserImage + else: + raise UserError(f'gridmode has to be {self.GRIDMODE_GRID} or {self.GRIDMODE_LIST}') + + self.clear_widgets() + for itm in range(self.page_size): + wx = factory( + browser=self, + scolor=self.root.session.cfg('ui', 'standalone', 'browser', 'select_color'), + ) + self.bind(selection=wx.on_selection) + self.bind(cursor=wx.on_cursor) + self.add_widget(wx) + + def _cell_resolution(self): + return Resolution(self.width/self.cols, self.height/self.rows) + + def on_change_view(self, wx, change_view): + # the view will be updated, hence preloading should be interrupted + # if it were active. That's done here since to capture the earliest + # time where a view change becomes apparent. + if change_view and self._draw_view_evt is not None: + self._draw_view_evt.cancel() + self._draw_view_evt = None + + def draw_view(self): + self._draw_view_evt = None + # revoke images that are still wait to being loaded + Loader.clear() + #if not self.root.session.cfg('ui', 'standalone', 'browser', 'cache_all'): + # Loader.clear() + + # fetch items + items = self.items[self.offset:self.offset+self.page_size] + childs = iter(self.children) # reversed since child widgets are in reverse order + + # preload neighbouring pages + n_pages = self.root.session.cfg('ui', 'standalone', 'browser', 'cache_items') + n_pages = math.ceil(n_pages / self.page_size) + if n_pages > 0: + lo = clamp(self.offset - n_pages * self.page_size, self.n_items) + cu = clamp(self.offset + self.page_size, self.n_items) + hi = clamp(self.offset + (n_pages + 1) * self.page_size, self.n_items) + # load previous page + # previous before next such that scrolling downwards is prioritised + self._preload_items(self.items[lo:self.offset]) + # load next page + # reversed such that the loader prioritises earlier previews + self._preload_items(reversed(self.items[cu:hi])) + + # clear unused cells + for _ in range(self.page_size - len(items)): + next(childs).clear() + + # load previews for items + # FIXME: Only relevant items, not all of them + thumbs = items.preview(default=open(resource_find('no_preview.png'), 'rb')) + resolution = self._cell_resolution() + for ent, thumb, child in zip(reversed(items), reversed(thumbs), childs): + # FIXME: default/no preview handling + thumb = best_resolution_match(thumb, resolution) + child.update(ent, thumb, f'{ent.guid}x{resolution}') + + # load previews for items + #resolution = self._cell_resolution() + #for obj, child in zip(reversed(items), childs): + # try: + # thumb = obj.get('preview').best_match(resolution) + # except PredicateNotSet: + # thumb = open(resource_find('no_preview.png'), 'rb') + # child.update(obj, thumb, f'{obj.guid}x{resolution}') + + #def _preload_all(self): + # # prefer loading from start to end + # self._preload_items(reversed(self.items)) + + def _preload_items(self, items, resolution=None): + """Load an item into the kivy *Cache* without displaying the image anywhere.""" + resolution = resolution if resolution is not None else self._cell_resolution() + + def _buf_loader(buffer, fname): + # helper method to load the image from a raw buffer + with buffer as buf: + return ImageLoaderTagit(filename=fname, inline=True, rawdata=buf) + + for obj in items: + try: + buffer = obj.get('preview').best_match(resolution) + source = f'{obj.guid}x{resolution}' + + Loader.image(source, + nocache=False, mipmap=False, + anim_delay=0, + load_callback=partial(_buf_loader, buffer) # mb: pass load_callback + ) + + except PredicateNotSet: + pass + + +class BrowserAwareMixin(object): + """Widget that binds to the browser.""" + browser = None + def on_root(self, wx, root): + root.bind(browser=self.on_browser) + if root.browser is not None: + # initialize with the current browser + # Going through the event dispatcher ensures that the object + # is initialized properly before on_browser is called. + Clock.schedule_once(lambda dt: self.on_browser(root, root.browser)) + + def on_browser(self, sender, browser): + pass + + +class BrowserItem(RelativeLayout): + """Just some space for an object.""" + browser = kp.ObjectProperty() + obj = kp.ObjectProperty(allownone=True) + is_cursor = kp.BooleanProperty(False) + is_selected = kp.BooleanProperty(False) + is_group = kp.BooleanProperty(False) + scolor = kp.ListProperty([1, 0, 0]) # FIXME: set from config + + def update(self, obj): + self.obj = obj + + def clear(self): + self.obj = None + + def on_obj(self, wx, obj): + self.on_cursor(self.browser, self.browser.cursor) + self.on_selection(self.browser, self.browser.selection) + self.is_group = obj in self.browser.folds if obj is not None else False + + def on_cursor(self, browser, cursor): + self.is_cursor = (cursor == self.obj) if self.obj is not None else False + + def on_selection(self, browser, selection): + self.is_selected = self.obj in selection if self.obj is not None else False + + def on_touch_down(self, touch): + """Click on item.""" + if self.obj is not None and self.collide_point(*touch.pos): + if touch.button == 'left': + # shift counts as double tap + if touch.is_double_tap and not self.browser.root.keys.shift_pressed: + # open + logger.debug('Item: Double touch in {}'.format(str(self.obj))) + if not self.is_selected: + self.browser.root.trigger('Select', self.obj) + self.browser.root.trigger('OpenExternal') + else: + # set cursor + logger.debug('Item: Touchdown in {}'.format(str(self.obj))) + self.browser.root.trigger('SetCursor', self.obj) + + # must call the parent's method to ensure OpenGroup gets a chance to handle + # the mouse event. Also, this must happen *after* processing the event here + # so that the cursor is set correctly. + return super(BrowserItem, self).on_touch_down(touch) + + def on_touch_move(self, touch): + """Move over item.""" + if self.obj is not None and self.collide_point(*touch.pos): + if touch.button == 'left': + if not self.collide_point(*touch.ppos): + self.browser.root.trigger('Select', self.obj) + return super(BrowserItem, self).on_touch_move(touch) + + +class BrowserImage(BrowserItem): + def update(self, obj, buffer, source): + super(BrowserImage, self).update(obj) + self.preview.load_image(buffer, source, obj.p.get('orientation', 1)) + self.preview.set_size(self.size) + + def clear(self): + super(BrowserImage, self).clear() + self.preview.clear_image() + + def on_size(self, wx, size): + self.preview.set_size(self.size) + + +class BrowserDescription(BrowserItem): + text = kp.StringProperty() + + def update(self, obj, buffer, source): + super(BrowserDescription, self).update(obj) + self.preview.load_image(buffer, source, obj.p.get('orientation', 1)) + self.preview.set_size((self.height, self.height)) + + def clear(self): + super(BrowserDescription, self).clear() + self.preview.clear_image() + + def on_size(self, wx, size): + self.preview.set_size((self.height, self.height)) + + def on_obj(self, wx, obj): + super(BrowserDescription, self).on_obj(wx, obj) + if self.is_group: + tags_all = set.intersection(*[set(m.tags) for m in obj.members()]) + tags_any = {t for m in obj.members() for t in m.tags} + self.text = '{name} [size=20sp]x{count}[/size], {mime}, {time}\n[color=8f8f8f][b]{tags_all}[/b], [size=14sp]{tags_any}[/size][/color]'.format(**dict( + name='group', #str(obj.group)[-6:].upper(), + count=len(list(obj.members())), + mime=self.obj.get('mime', ''), + time=ttime.from_timestamp_loc(self.obj.t_created).strftime("%Y-%m-%d %H:%M"), + tags_all=', '.join(sorted(tags_all)), + tags_any=', '.join(sorted(tags_any - tags_all)), + )) + else: + self.text = '[size=20sp]{filename}[/size], [size=18sp][i]{mime}[/i][/size] -- {time} -- {filesize}\n[color=8f8f8f][size=14sp]{tags}[/size][/color]'.format(**dict( + filename=os.path.basename(self.obj.path), + hash=str(self.obj), + mime=self.obj.get('mime', ''), + time=ttime.from_timestamp_loc(self.obj.t_created).strftime("%Y-%m-%d %H:%M"), + filesize=magnitude_fmt(self.obj.get('filesize', 0)), + tags=', '.join(sorted(self.obj.tag)), + )) + + +class AsyncBufferImage(AsyncImage): + """Replacement for kivy.uix.image.AsyncImage that allows to pass a *load_callback* + method. The load_callback (fu(filename) -> ImageLoaderTagit) can be used to read a file + from something else than a path. However, note that if caching is desired, a filename + (i.e. source) should still be given. + """ + orientation = kp.NumericProperty(1) + buffer = kp.ObjectProperty(None, allownone=True) + mirror = kp.BooleanProperty(False) + angle = kp.NumericProperty(0) + + def load_image(self, buffer, source, orientation): + self.orientation = orientation + self.buffer = buffer + # triggers actual loading + self.source = source + # make visible + self.opacity = 1 + + def clear_image(self): + # make invisible + self.opacity = 0 + + def set_size(self, size): + width, height = size + # swap dimensions if the image is rotated + self.size = (height, width) if self.orientation in (5,6,7,8) else (width, height) + # ensure the correct positioning via the center + self.center = width / 2.0, height / 2.0 + # note that the widget's bounding box will be overlapping with other grid + # cells, however the content will be confined in the correct grid box. + + def on_orientation(self, wx, orientation): + if orientation in (2, 4, 5, 7): # Mirror + self.mirror = True + if orientation in (3, 4): # Rotate 180deg + self.angle = 180 + elif orientation in (5, 6): # Rotate clockwise, 90 deg + self.angle = -90 + elif orientation in (7, 8): # Rotate counter-clockwise, 90 deg + self.angle = 90 + else: + self.angle = 0 + self.mirror = False + + @staticmethod + def loader(buffer, fname): + # helper method to load the image from a raw buffer + with buffer as buf: + return ImageLoaderTagit(filename=fname, inline=True, rawdata=buf) + + def _load_source(self, *args): + # overwrites method from parent class + source = self.source + if not source: + if self._coreimage is not None: + self._coreimage.unbind(on_texture=self._on_tex_change) + self._coreimage.unbind(on_load=self.post_source_load) + self.texture = None + self._coreimage = None + else: + if self._coreimage is not None: + # unbind old image + self._coreimage.unbind(on_load=self._on_source_load) + self._coreimage.unbind(on_error=self._on_source_error) + self._coreimage.unbind(on_texture=self._on_tex_change) + del self._coreimage + self._coreimage = None + + self._coreimage = image = Loader.image(self.source, + nocache=self.nocache, mipmap=self.mipmap, + anim_delay=self.anim_delay, + load_callback=partial(self.loader, self.buffer), # mb: pass load_callback + ) + + # bind new image + image.bind(on_load=self._on_source_load) + image.bind(on_error=self._on_source_error) + image.bind(on_texture=self._on_tex_change) + self.texture = image.texture + + +## config ## + +config.declare(('ui', 'standalone', 'browser', 'cols'), config.Unsigned(), 3, + __name__, 'Browser columns', 'Default number of columns in the browser. Is at least one. Note that an odd number of columns and rows gives a more intuitive behaviour when zooming. A large value might make the program respond slowly.') + +config.declare(('ui', 'standalone', 'browser', 'rows'), config.Unsigned(), 3, + __name__, 'Browser rows', 'Default number of rows in the grid view. Is at least one. Note that an odd number of columns and rows gives a more intuitive behaviour when zooming. A large value might make the program respond slowly.') + +config.declare(('ui', 'standalone', 'browser', 'fold_threshold'), config.Unsigned(), 1, + __name__, 'Folding', "Define at which threshold groups will be folded. The default (1) folds every group unless it consists of only a single item (which isn't really a group anyhow).") + +config.declare(('ui', 'standalone', 'browser', 'gridmode'), + config.Enum(Browser.GRIDMODE_GRID, Browser.GRIDMODE_LIST), Browser.GRIDMODE_GRID, + __name__, 'Display style', 'The grid mode shows only the preview image of each item. The list mode shows the preview and some additional information of each item. Note that rows and cols can be specified for both options. It is recommended that they are set to the same value in grid mode, and to a single column in list mode.') + +config.declare(('ui', 'standalone', 'browser', 'cache_items'), config.Unsigned(), 20, + __name__, 'Page pre-loading', 'Number of items that are loaded into the cache before they are actually shown. The effective number of loaded items the specified value rounded up to the page size times two (since it affects pages before and after the current one). E.g. a value of one loads the page before and after the current one irrespective of the page size. If zero, preemptive caching is disabled.') + +config.declare(('ui', 'standalone', 'browser', 'page_delay'), config.Unsigned(), 50, + __name__, 'Page setup delay', 'Quickly scrolling through pages incurs an overhead due to loading images that will be discarded shortly afterwards. This overhead can be reduced by delaying the browser page setup for a short amount of time. If small enough the delay will not be noticable. Specify in milliseconds. Set to zero to disable the delay completely.') + +# FIXME: Also add select_alpha or maybe even select_style (left/right/over/under bar; overlay; recolor; others?) +# FIXME: Also add cursor style config (left/right/under/over bar; borders; others?) +config.declare(('ui', 'standalone', 'browser', 'select_color'), + config.List(config.Unsigned()), [0,0,1], + __name__, '', '') # FIXME + +#config.declare(('ui', 'standalone', 'browser', 'cache_all'), config.Bool(), False, +# __name__, 'Cache everything', 'Cache all preview images in the background. The cache size (`ui.standalone.browser.cache_size`) should be large enough to hold the library at least once (some reserve for different resolutions is advised). Can incur a small delay when opening the library. May consume a lot of memory.') + +## EOF ## diff --git a/tagit/widgets/context.kv b/tagit/widgets/context.kv new file mode 100644 index 0000000..75f5267 --- /dev/null +++ b/tagit/widgets/context.kv @@ -0,0 +1,25 @@ +#:import ContextMenu tagit.external.kivy_garden.contextmenu.ContextMenu + +<Context>: + menu: context_menu + visible: False + # the root widget should set these two to itself + bounding_box_widget: self + cancel_handler_widget: self + # button config + button_width: 200 + button_height: dp(35) + button_show: 'text', 'image' + + ContextMenu: # the actual menu + id: context_menu + visible: False + cancel_handler_widget: root + bounding_box_widget: root.bounding_box_widget + width: root.button_width + +<ContextMenuAction>: + width: self.parent.width if self.parent else 0 + size_hint: 1, None + +## EOF ## diff --git a/tagit/widgets/context.py b/tagit/widgets/context.py new file mode 100644 index 0000000..2affbed --- /dev/null +++ b/tagit/widgets/context.py @@ -0,0 +1,148 @@ +""" + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 +""" +# standard imports +import logging +import os + +# kivy imports +from kivy.lang import Builder +from kivy.uix.floatlayout import FloatLayout +import kivy.properties as kp + +# tagit imports +from tagit import config +from tagit.utils.builder import InvalidFactoryName +from tagit.actions import ActionBuilder +from tagit.external.kivy_garden.contextmenu import ContextMenuItem, AbstractMenuItemHoverable, ContextMenuTextItem, ContextMenu + +# inner-module imports +from .dock import DockBase + +# exports +__all__ = ('Context', ) + + +## code ## + +logger = logging.getLogger(__name__) + +# load kv +Builder.load_file(os.path.join(os.path.dirname(__file__), 'context.kv')) + +# classes +class ContextMenuAction(ContextMenuItem, AbstractMenuItemHoverable): + """Wraps a context menu item around an action buttons.""" + # menu requirements + submenu_postfix = kp.StringProperty(' ...') + color = kp.ListProperty([1,1,1,1]) + # action requirements + action = kp.ObjectProperty(None) + hide_fu = kp.ObjectProperty(None) + + @property + def content_width(self): + """Forward the width from the action button.""" + if self.action is None: + return 0 + return self.action.width + + def set_action(self, action): + """Add the action button.""" + self.add_widget(action) + self.action = action + return self + + def on_touch_up(self, touch): + """Close the menu when an action is triggered.""" + if self.collide_point(*touch.pos) and \ + touch.button == 'left' and \ + self.hide_fu is not None: + self.action.on_release() + self.hide_fu() + return super(ContextMenuAction, self).on_touch_up(touch) + + +class Context(FloatLayout, DockBase): + """Context menu.""" + root = kp.ObjectProperty(None) + + def show(self, x, y): + """Open the menu.""" + self.menu.show(x, y) + + def on_touch_down(self, touch): + """Open the menu via click.""" + if touch.button == 'right': + self.show(*touch.pos) + return super(Context, self).on_touch_down(touch) + + def on_config_changed(self, session, key, value): + if key == ('ui', 'standalone', 'context'): + self.on_cfg(session, session.cfg) + + def on_cfg(self, wx, cfg): + """Construct the menu from config.""" + self.populate(cfg('ui', 'standalone', 'context')) + + def populate(self, actions): + """Construct the menu.""" + # clear old menu items + childs = [child for child in self.menu.children if isinstance(child, ContextMenuTextItem)] + childs += [child for child in self.menu.children if isinstance(child, ContextMenuAction)] + for child in childs: + self.menu.remove_widget(child) + + # add new menu items + builder = ActionBuilder() + for menu, args in actions.items(): + if menu == 'root': + # add directly to the context menu + wx = self.menu + else: + # create and add a submenu + head = ContextMenuTextItem(text=menu) + self.menu.add_widget(head) + wx = ContextMenu(width=self.button_width) + head.add_widget(wx) + wx._on_visible(False) + + for action in args: + try: + cls = builder.get(action) + if action == 'SortKey': + # special case: add as submenu + btn = cls(root=self.root) + head = ContextMenuTextItem(text=btn.text) + wx.add_widget(head) + head.add_widget(btn.menu) + btn.menu._on_visible(False) + + else: + wx.add_widget(ContextMenuAction( + # args to the action wrapper + hide_fu=self.menu.hide, + height=self.button_height, + ).set_action(cls( + # args to the button + root=self.root, + autowidth=False, + size=(self.button_width, self.button_height), + size_hint=(1, None), + show=self.button_show, + ))) + + except InvalidFactoryName: + logger.error(f'invalid button name: {action}') + + +## config ## + +config.declare(('ui', 'standalone', 'context'), + config.Dict(config.String(), config.List(config.Enum(set(ActionBuilder.keys())))), {}, + __name__, 'Context menu structure', 'The context menu consists of groups of actions, similar to the button dock. Each group consists of a name and a list of actions. To add actions to the menu directly, use "root" for the group name.', '{"root": ["ShowDashboard", "ShowBrowsing"], "search": ["GoBack", "GoForth"]}') + +## EOF ## diff --git a/tagit/widgets/desktop.kv b/tagit/widgets/desktop.kv index 5d6c8f2..9ebd08d 100644 --- a/tagit/widgets/desktop.kv +++ b/tagit/widgets/desktop.kv @@ -1,5 +1,5 @@ -#:import TileDecorationBorder tagit.uix.kivy.tiles.decoration.TileDecorationBorder -#:import TileDecorationFilledRectangle tagit.uix.kivy.tiles.decoration.TileDecorationFilledRectangle +#:import TileDecorationBorder tagit.tiles.decoration.TileDecorationBorder +#:import TileDecorationFilledRectangle tagit.tiles.decoration.TileDecorationFilledRectangle # DEBUG: Draw borders around all widgets #<Widget>: diff --git a/tagit/widgets/desktop.py b/tagit/widgets/desktop.py index 364c4ec..f012fc7 100644 --- a/tagit/widgets/desktop.py +++ b/tagit/widgets/desktop.py @@ -2,26 +2,30 @@ Part of the tagit module. A copy of the license is provided with the project. -Author: Matthias Baumgartner, 2016 +Author: Matthias Baumgartner, 2022 """ -# imports +# standard imports +import logging +import os +import typing + +# kivy imports from kivy.clock import Clock from kivy.lang import Builder from kivy.uix.floatlayout import FloatLayout -from os.path import join, dirname import kivy.properties as kp -import logging # import Image and Loader to overwrite their caches later on from kivy.loader import Loader from kivy.cache import Cache -# inner-module imports +# tagit imports +from tagit import actions from tagit import config -import tagit.uix.kivy.dialogues as dialogue -# tagit widget imports -from .actions import ActionBuilder +from tagit import dialogues + +# inner-module imports from .browser import Browser from .context import Context from .dock import TileDock, ButtonDock, KeybindDock @@ -32,7 +36,11 @@ from .status import Status from .tabs import Tab # exports -__all__ = ('MainWindow', 'KIVY_IMAGE_CACHE_SIZE', 'KIVY_IMAGE_CACHE_TIMEOUT') +__all__: typing.Sequence[str] = ( + 'KIVY_IMAGE_CACHE_SIZE', + 'KIVY_IMAGE_CACHE_TIMEOUT', + 'MainWindow', + ) ## code ## @@ -40,7 +48,7 @@ __all__ = ('MainWindow', 'KIVY_IMAGE_CACHE_SIZE', 'KIVY_IMAGE_CACHE_TIMEOUT') logger = logging.getLogger(__name__) # load kv -Builder.load_file(join(dirname(__file__), 'desktop.kv')) +Builder.load_file(os.path.join(os.path.dirname(__file__), 'desktop.kv')) # classes class MainWindow(FloatLayout): @@ -87,7 +95,7 @@ class MainWindow(FloatLayout): def trigger(self, action, *args, **kwargs): """Trigger an action once.""" - ActionBuilder().get(action).single_shot(self, *args, **kwargs) + actions.ActionBuilder().get(action).single_shot(self, *args, **kwargs) ## functions @@ -213,7 +221,7 @@ class MainWindow(FloatLayout): Since you see this message, it's time to configure tagit. It's a good idea to get familiar with the configuration. Hit F1 or the config button to see all relevant settings. There, you can also get rid of this message. If you desire more flexibility, you can edit the config file directly. Check out the project homepage for more details. """ # FIXME! - dialogue.Message(text=message, align='left').open() + dialogues.Message(text=message, align='left').open() ## config ## diff --git a/tagit/widgets/dock.kv b/tagit/widgets/dock.kv new file mode 100644 index 0000000..4d82ac3 --- /dev/null +++ b/tagit/widgets/dock.kv @@ -0,0 +1,20 @@ +#:import TileDecorationVanilla tagit.tiles.decoration.TileDecorationVanilla + +<TileDock>: + cols: 3 + rows: 3 + decoration: TileDecorationVanilla + visible: False + tile_height: None + tile_width: None + name: '' + +<ButtonDock>: + orientation: 'lr-tb' + button_height: 30 + button_width: self.button_height + button_show: 'image', + n_buttons_max: None + name: '' + +## EOF ## diff --git a/tagit/widgets/dock.py b/tagit/widgets/dock.py new file mode 100644 index 0000000..41ff642 --- /dev/null +++ b/tagit/widgets/dock.py @@ -0,0 +1,239 @@ +""" + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 +""" +# standard imports +import logging +import os + +# kivy imports +from kivy.lang import Builder +from kivy.uix.gridlayout import GridLayout +from kivy.uix.stacklayout import StackLayout +from kivy.uix.widget import Widget +import kivy.properties as kp + +# tagit imports +from tagit import config +from tagit.actions import ActionBuilder +from tagit.tiles import TileBuilder +from tagit.utils import errors +from tagit.utils.builder import InvalidFactoryName + +# inner-module imports +from .session import ConfigAwareMixin + +# exports +__all__ = ('Dock', ) + + +## code ## + +logger = logging.getLogger(__name__) + +# load kv +Builder.load_file(os.path.join(os.path.dirname(__file__), 'dock.kv')) + +# classes +class DockBase(Widget, ConfigAwareMixin): + """A Dock is a container that holds configurable items.""" + # root reference + root = kp.ObjectProperty(None) + + def on_cfg(self, wx, cfg): + """Construct the dock from config.""" + errors.abstract() + + def populate(self, config): + """Fill the dock with content.""" + errors.abstract() + + +class TileDock(GridLayout, DockBase): + """A TileDock holds a number of Tiles.""" + + # dock's name for loading from config + name = kp.StringProperty('') + # tile decoration + decoration = kp.ObjectProperty(None) + # tile visiblity + visible = kp.BooleanProperty(False) + + def on_config_changed(self, session, key, value): + if key == ('ui', 'standalone', 'tiledocks'): + self.on_cfg(session, session.cfg) + + def on_cfg(self, wx, cfg): + """Construct the Tiles from the config item matching dock's name.""" + if self.name != '': + self.populate(cfg('ui', 'standalone', 'tiledocks').get(self.name, {})) + # FIXME: Since dictionaries are not ordered, the tiles might change + # their position at every application start. Switching to a list would + # solve this issue. E.g. [{tile: 'tile name', **kwargs}] + + def populate(self, tiles): + """Construct the Tiles.""" + # clear old items + self.clear_widgets() + + # add new items + n_tiles_max = self.cols * self.rows + builder = TileBuilder() + for idx, tid in enumerate(sorted(tiles)): + if idx >= n_tiles_max: + logger.warn(f'number of tiles exceeds space ({len(tiles)} > {n_tiles_max})') + break + + try: + kwargs = tiles[tid] + tile = builder.build(tid, root=self.root, **kwargs) + self.add_widget(self.decoration(client=tile)) + except InvalidFactoryName: + logger.error(f'invalid tile name: {tid}') + + # create and attach widgets before setting visibility + # to ensure that the widget initialization has finished. + self.on_visible(self, self.visible) + + def on_size(self, *args): + # FIXME: If dashboard is loaded, resizing the window becomes painfully slow. + # Something to do with the code here, e.g. delayed sizing? + for child in self.children: + # TODO: Allow default_size or tile_size to specify relative sizes (<1) + # determine size + width = self.tile_width + width = child.default_size[0] if width is None else width + #width = self.width if width is None and self.size_hint_x is None else width + height = self.tile_height + height = child.default_size[1] if height is None else height + #height = self.height if height is None and self.size_hint_y is None else height + size = width if width is not None else 1, height if height is not None else 1 + size_hint = None if width is not None else 1, None if height is not None else 1 + # set size; will be propagated from the decorator to the client + child.size = size + child.size_hint = size_hint + + def on_visible(self, wx, visible): + """Propagate visibility update to Tiles.""" + for child in self.children: + child.client.visible = visible + + # FIXME: move events in the browser are only triggered if the move event is also + # handled here with an empty body (no super!). + # No idea why this happens (e.g. doing it in desktop or tab doesn't work). + def on_touch_move(self, touch): + pass + + +class ButtonDock(StackLayout, DockBase): + """A ButtonDock holds a number of Actions.""" + + # dock's name for loading from config + name = kp.StringProperty('') + + def on_config_changed(self, session, key, value): + if key == ('ui', 'standalone', 'buttondocks'): + self.on_cfg(session, session.cfg) + + def on_cfg(self, wx, cfg): + """Construct the Actions from config item matching the dock's name.""" + if self.name != '': + # name is empty if created via the Buttons tile + self.populate(cfg('ui', 'standalone', 'buttondocks').get(self.name, [])) + + def populate(self, actions): + """Construct the Actions.""" + # clear old items + self.clear_widgets() + + # add new items + n_buttons_max = float('inf') if self.n_buttons_max is None else self.n_buttons_max + builder = ActionBuilder() + for idx, action in enumerate(actions): + if idx >= n_buttons_max: + logger.warn(f'number of buttons exceeds space ({len(actions)} > {n_buttons_max})') + break + + try: + self.add_widget(builder.build(action, + root=self.root, + size=(self.button_width, self.button_height), + show=self.button_show, + autowidth=False, + )) + except InvalidFactoryName: + logger.error(f'invalid button name: {action}') + + +class KeybindDock(DockBase): + """The KeybindDock holds a number of invisible Actions that can be triggered by key presses.""" + + def on_config_changed(self, session, key, value): + if key == ('ui', 'standalone', 'keytriggers'): + self.on_cfg(session, session.cfg) + + def on_cfg(self, wx, cfg): + """Construct the Actions from config.""" + self.populate(cfg('ui', 'standalone', 'keytriggers')) + + def populate(self, actions): + """Construct the Actions.""" + # clear old items + self.clear_widgets() + + # add new items + builder = ActionBuilder() + for action in actions: + try: + self.add_widget(builder.build( + action, + root=self.root, + # process key events only + touch_trigger=False, + key_trigger=True, + # no need to specify show (default is empty) + )) + + except InvalidFactoryName: + logger.error(f'invalid button name: {action}') + + +## config ## + +config.declare(('ui', 'standalone', 'keytriggers'), + config.List(config.Enum(set(ActionBuilder.keys()))), [], + __name__, 'Key triggers', + 'Actions that can be triggered by a key but have no visible button', '') + +config.declare(('ui', 'standalone', 'tiledocks'), + config.Dict(config.String(), config.Dict(config.String(), config.Dict(config.String(), config.Any()))), {}, + __name__, 'Tile docks', '''Tiles can be placed in several locations of the UI. A tile usually displays some information about the current program state, such as information about the library in general, visible or selected items, etc. + +The configuration of a tile consists the its name as string and additional parameters to that tile as a dict. A tile dock is configured by a dictionary with the tile names as key and their parameters as value: + +{ + "Hints": {}, + "ButtonDock": {"buttons: ["Save", "SaveAs", "Index"]} +} + +The order of the items in the UI is generally the same as in the config dict. + +To show a list of available tiles, execute: + +$ tagger info tile + +''') + +config.declare(('ui', 'standalone', 'buttondocks'), + config.Dict(config.String(), config.List(config.Enum(set(ActionBuilder.keys())))), {}, + __name__, 'Buttons', '''Every possible action in the UI is triggered via a button. Hence, buttons are found in various places in the UI, organized in button docks. Each dock is identified by name and lists the names of the buttons it contains. + +To show a list of available buttons, execute: + +$ tagger info action + +''') + +## EOF ## diff --git a/tagit/widgets/filter.kv b/tagit/widgets/filter.kv new file mode 100644 index 0000000..d98b5a7 --- /dev/null +++ b/tagit/widgets/filter.kv @@ -0,0 +1,83 @@ +#:import SearchmodeSwitch tagit.actions.filter + +<Filter>: + root: None + orientation: 'horizontal' + spacing: 5 + tokens: tokens + + BoxLayout: + orientation: 'horizontal' + spacing: 10 + id: tokens + + # Tokens will be inserted here + + SearchmodeSwitch: + show: 'image', + root: root.root + + SortKey: + show: 'image', + root: root.root + + SortOrder: + show: 'image', + root: root.root + + ButtonDock: + root: root.root + name: 'filter' + orientation: 'lr-tb' + # space for 2 buttons + width: 3*30 + 2*5 + size_hint: None, 1.0 + spacing: 5 + button_height: 30 + button_show: 'image', + +<Shingle>: + orientation: 'horizontal' + label: tlabel + + canvas.before: + Color: + rgba: 0,0,1, 0.25 if root.active else 0 + Rectangle: + pos: root.pos + size: root.size + + canvas.after: + Color: + rgba: 1,1,1,1 + Line: + rectangle: self.x+1, self.y+1, self.width-1, self.height-1 + + Label: + id: tlabel + text: root.text + + canvas.after: + Color: + rgba: 0,0,0,0.5 if not root.active else 0 + Rectangle: + pos: self.pos + size: self.size + + + Button: + text: 'x' + bold: True + opacity: 0.5 + width: 20 + size_hint: None, 1.0 + background_color: [0,0,0,0] + background_normal: '' + on_press: root.remove() + +<Addressbar>: + multiline: False + background_color: (0.2,0.2,0.2,1) if self.focus else (0.15,0.15,0.15,1) + foreground_color: (1,1,1,1) + +## EOF ## diff --git a/tagit/widgets/filter.py b/tagit/widgets/filter.py new file mode 100644 index 0000000..56d460a --- /dev/null +++ b/tagit/widgets/filter.py @@ -0,0 +1,301 @@ +""" + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 +""" +# standard imports +from functools import partial +import logging +import os + +# kivy imports +from kivy.clock import Clock +from kivy.config import Config as KivyConfig +from kivy.lang import Builder +from kivy.uix.behaviors import ButtonBehavior +from kivy.uix.behaviors import FocusBehavior +from kivy.uix.boxlayout import BoxLayout +from kivy.uix.image import Image +from kivy.uix.textinput import TextInput +import kivy.properties as kp + +# tagit imports +from tagit import config +#from tagit.parsing.search import ast, ast_to_string # FIXME: mb/port +from tagit.utils import errors + +# inner-module imports +from .session import ConfigAwareMixin + +# exports +__all__ = ('Filter', ) + + +## code ## + +logger = logging.getLogger(__name__) + +# load kv +Builder.load_file(os.path.join(os.path.dirname(__file__), 'filter.kv')) + +# classes +class Filter(BoxLayout, ConfigAwareMixin): + """ + A filter tracks a sequence of searches building on top of each other. Each + item in that sequence is defined by a part of the overall search query + (token). In addition, the filter also tracks the viewport at each point in + the sequence (frames). + + In addition, the sequence can be navigated back-and-forth, so that the + current search includes a number of items, starting at the front, but not + necessarily all. Hence, some tokens are present in the current + search (head), while others are not (tail). + """ + # root reference + root = kp.ObjectProperty(None) + + # change notification + changed = kp.BooleanProperty(False) + run_search = kp.BooleanProperty(False) + + # appearance + MODE_SHINGLES = 'shingles' + MODE_ADDRESS = 'address' + searchmode = kp.OptionProperty(MODE_SHINGLES, options=[MODE_SHINGLES, MODE_ADDRESS]) + + ''' + To track head, tail, tokens, and frames, four properties are used for + the relevant pairwise combinations. + + For heads, the frame is the last known viewport before applying the + next filter token. I.e. f_head[1] corresponds to the search including + tokens t_head[:1]. The viewport of the current search is maintained + in the browser. + + For tails, the frame is the last viewport before switching to the previous + filter token. I.e. f_tail[1] corresponds to the search including + tokens t_tail[:2] (i.e. the lists are aligned). + + Consider the following scheme. + The current search is indicated by the "v". The first search includes + no tokens (all items). Note the offset between tokens and frames in + the head part. + + v + view 0 1 2 3 4 + token - 0 1 2 3 0 1 + frame 0 1 2 3 - 0 1 + + Although the lists are not necessarily aligned, they always have to have + the same size. This constraint is enforced. + + ''' + # tokens + t_head = kp.ListProperty() + t_tail = kp.ListProperty() + + # frames + f_head = kp.ListProperty() + f_tail = kp.ListProperty() + + # sort + #sortkey = kp.ObjectProperty(partial(ast.NumericalSort, 'time')) + sortkey = kp.ObjectProperty() # FIXME: mb/port + sortdir = kp.BooleanProperty(False) # False means ascending + + + ## exposed methods + + def get_query(self): + query = ast.AND(self.t_head[:]) if len(self.t_head) else None + # sort order is always set to False so that changing the sort order + # won't trigger a new query which can be very expensive. The sort + # order is instead applied in uix.kivy.actions.search.Search. + sort = self.sortkey(False) if self.sortkey is not None else None + return query, sort + + def abbreviate(self, token): + if token.predicate() == 'tag': + return ','.join(list(token.condition())) + elif token.predicate() == 'entity': + return 'R' if isinstance(token.condition(), ast.SetInclude) else 'E' + else: + return { + 'group' : 'G', + 'time' : 'T', + 'altitude' : 'Alt', + 'longitude' : 'Lon', + 'latitude' : 'Lat', + }.get(token.predicate(), token.predicate().title()) + + def show_address_once(self): + """Single-shot address mode without changing the search mode.""" + self.tokens.clear_widgets() + searchbar = Addressbar(self.t_head, root=self.root) + self.tokens.add_widget(searchbar) + searchbar.focus = True + + + ## initialization + + def on_config_changed(self, session, key, value): + if key == ('ui', 'standalone', 'filter', 'searchbar'): + self.on_cfg(session, session.cfg) + + def on_cfg(self, wx, cfg): + with self: + self.searchmode = cfg('ui', 'standalone', 'filter', 'searchbar') + + ## filter as context + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + if not(len(self.t_head) == len(self.f_head)): + raise errors.ProgrammingError('head sizes differ') + if not(len(self.t_tail) == len(self.f_tail)): + raise errors.ProgrammingError('tail sizes differ') + + # issue redraw + if self.changed: + self.redraw() + # issue search + if self.run_search: + self.root.trigger('Search') + + def redraw(self): + self.tokens.clear_widgets() + if self.searchmode == self.MODE_ADDRESS: + # add address bar + self.tokens.add_widget(Addressbar(self.t_head, root=self.root)) + + elif self.searchmode == self.MODE_SHINGLES: + # add shingles + for tok in self.t_head + self.t_tail: + self.tokens.add_widget( + Shingle( + tok, + active=(tok in self.t_head), + text=self.abbreviate(tok), + root=self.root + )) + + ## property access + + def on_t_head(self, sender, t_head): + self.changed = True + self.run_search = True + + def on_t_tail(self, sender, t_tail): + self.changed = True + + def on_searchmode(self, sender, mode): + self.changed = True + + def on_sortdir(self, sender, sortdir): + self.run_search = True + + def on_sortkey(self, sender, sortkey): + self.run_search = True + + +class FilterAwareMixin(object): + """Tile that binds to the filter.""" + filter = None + def on_root(self, wx, root): + root.bind(filter=self.on_filter) + if root.filter is not None: + # initialize with the current filter + # Going through the event dispatcher ensures that the object + # is initialized properly before on_filter is called. + Clock.schedule_once(lambda dt: self.on_filter(root, root.filter)) + + def on_filter(self, sender, filter): + pass + + +class Shingle(BoxLayout): + """A sequence of filter tokens. Tokens can be edited individually.""" + # root reference + root = kp.ObjectProperty(None) + + # content + active = kp.BooleanProperty(False) + text = kp.StringProperty('') + + # touch behaviour + _single_tap_action = None + + def __init__(self, token, **kwargs): + super(Shingle, self).__init__(**kwargs) + self.token = token + + def remove(self, *args, **kwargs): + """Remove shingle.""" + self.root.trigger('RemoveToken', self.token) + + def on_touch_down(self, touch): + """Edit shingle when touched.""" + if self.label.collide_point(*touch.pos): + if touch.is_double_tap: # edit filter + # ignore touch, such that the dialogue + # doesn't loose the focus immediately after open + if self._single_tap_action is not None: + self._single_tap_action.cancel() + self._single_tap_action = None + FocusBehavior.ignored_touch.append(touch) + self.root.trigger('EditToken', self.token) + return True + else: # jump to filter + # delay executing the action until we're sure it's not a double tap + self._single_tap_action = Clock.schedule_once( + lambda dt: self.root.trigger('JumpToToken', self.token), + KivyConfig.getint('postproc', 'double_tap_time') / 1000) + return True + + return super(Shingle, self).on_touch_down(touch) + +class Addressbar(TextInput): + """An address bar where a search query can be entered and edited. + Edits are accepted by pressing Enter and rejected by pressing Esc. + """ + # root reference + root = kp.ObjectProperty() + + def __init__(self, tokens, **kwargs): + super(Addressbar, self).__init__(**kwargs) + self.text = ast_to_string(ast.AND(tokens)) + self._last_text = self.text + + def on_text_validate(self): + """Accept text as search string.""" + self.root.trigger('SetToken', self.text) + self._last_text = self.text + + def on_keyboard(self, *args, **kwargs): + """Block key propagation to other widgets.""" + return True + + def on_focus(self, wx, focus): + from kivy.core.window import Window + if focus: + # fetch keyboard + Window.bind(on_keyboard=self.on_keyboard) + # keep a copy of the current text + self._last_text = self.text + else: + # release keyboard + Window.unbind(on_keyboard=self.on_keyboard) + # set last accepted text + self.text = self._last_text + + +## config ## + +config.declare(('ui', 'standalone', 'filter', 'searchbar'), + config.Enum('shingles', 'address'), 'shingles', + __name__, 'Searchbar mode', 'Show either list of shingles, one per search token, or a freely editable address bar.') + +## EOF ## diff --git a/tagit/widgets/keyboard.py b/tagit/widgets/keyboard.py new file mode 100644 index 0000000..2cae7d6 --- /dev/null +++ b/tagit/widgets/keyboard.py @@ -0,0 +1,142 @@ +""" + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 +""" +# kivy imports +from kivy.uix.widget import Widget +import kivy.properties as kp + +# exports +__all__ = [] + + +## code ## + +class Keyboard(Widget): + """Captures key events and turns them into simplified events. + Keeps a record of currently pressed modifiers (CTRL, SHIFT, etc.). + """ + + # modifiers + MODIFIERS_NONE = 0b00000 # 0 + MODIFIERS_CTRL = 0b00001 # 1 + MODIFIERS_SHIFT = 0b00010 # 2 + MODIFIERS_ALT = 0b00100 # 4 + MODIFIERS_ALTGR = 0b01000 # 8 + MODIFIERS_CMD = 0b10000 # 16 + + # modifier keymaps + keymap = { + 303: MODIFIERS_SHIFT, # right shift + 304: MODIFIERS_SHIFT, # left shift + 305: MODIFIERS_CTRL, # left ctrl + 306: MODIFIERS_CTRL, # right ctrl + 307: MODIFIERS_ALTGR, + 308: MODIFIERS_ALT, + 309: MODIFIERS_CMD, # a.k.a. windows key + } + + modemap = { + MODIFIERS_SHIFT: (303, 304), + MODIFIERS_CTRL: (305, 306), + MODIFIERS_ALTGR: (307, ), + MODIFIERS_ALT: (308, ), + MODIFIERS_CMD: (309, ), + } + + # current mode + mode = kp.NumericProperty(MODIFIERS_NONE) + + # state access via properties + + @property + def none_pressed(self): + return self.mode & self.MODIFIERS_NONE + + @property + def ctrl_pressed(self): + return self.mode & self.MODIFIERS_CTRL + + @property + def shift_pressed(self): + return self.mode & self.MODIFIERS_SHIFT + + @property + def alt_pressed(self): + return self.mode & self.MODIFIERS_ALT + + @property + def altgr_pressed(self): + return self.mode & self.MODIFIERS_ALTGR + + @property + def cmd_pressed(self): + return self.mode & self.MODIFIERS_CMD + + + ## outbound events + + __events__ = ('on_press', 'on_release') + + def on_press(sender, evt): + """Key press event prototype.""" + pass + + def on_release(sender, evt): + """Key release event prototype.""" + pass + + + ## event rewriting + + def __init__ (self, **kwargs): + super(Keyboard, self).__init__(**kwargs) + # keybindings + from kivy.core.window import Window + Window.bind(on_key_up=self.on_key_up) + Window.bind(on_key_down=self.on_key_down) + Window.bind(on_keyboard=self.on_keyboard) + + def __del__(self): + from kivy.core.window import Window + Window.unbind(on_key_up=self.on_key_up) + Window.unbind(on_key_down=self.on_key_down) + Window.unbind(on_keyboard=self.on_keyboard) + + def on_key_up(self, wx, key, scancode): + """Record modifier release.""" + mode = self.keymap.get(key, self.MODIFIERS_NONE) + self.mode -= self.mode & mode + self.dispatch('on_release', key) + + def on_key_down(self, wx, key, scancode, char, modifiers): + """Record modifiers press.""" + mode = self.keymap.get(key, self.MODIFIERS_NONE) + self.mode |= mode + + def on_keyboard(self, wx, key, scancode, char, modifiers): + """Forward key presses Handles keybindings. Is called when a key press is detected. + + *key* : ASCII or ASCII-like value + *scancode* : Key code returned by the input provider (e.g. keyboard) + *char* : String representation (if A-Z, a-z) + *modifiers* : 'ctrl', 'shift', 'alt', or any combination thereof, if pressed + + """ + if False: + # print key event for debugging + print(f"""Keybindings: Event + Key : {key} + Scancode : {scancode} + Codepoint : {char} + Modifiers : {modifiers} + """) + + # forward compact event to widgets + self.dispatch('on_press', (key, char, modifiers)) + # prevent further event propagation + return True + +## EOF ## diff --git a/tagit/widgets/loader.py b/tagit/widgets/loader.py new file mode 100644 index 0000000..9c0ffaf --- /dev/null +++ b/tagit/widgets/loader.py @@ -0,0 +1,200 @@ +""" + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 +""" +# standard imports +import time +import typing + +# kivy imports +from kivy.cache import Cache +from kivy.clock import Clock +from kivy.compat import queue +from kivy.loader import _Worker, LoaderThreadPool, ProxyImage, LoaderBase + +# exports +__all__: typing.Sequence[str] = ( + 'Loader', + ) + + +## code ## + +class _ThreadPool(object): + """Pool of threads consuming tasks from a queue. + Identical to kivy.loader._ThreadPool except for the queue type.""" + def __init__(self, num_threads): + super(_ThreadPool, self).__init__() + self.running = True + self.tasks = queue.LifoQueue() # mb: replace Queue with LifoQueue + for _ in range(num_threads): + _Worker(self, self.tasks) + + def add_task(self, func, *args, **kargs): + self.tasks.put((func, args, kargs)) + + def stop(self): + self.running = False + self.tasks.join() + + +class TagitImageLoader(LoaderThreadPool): + """Threaded Loader that prioritises recentness. + This is useful if a user skips through browser pages because then the preview loading + finishes only after the user has already switched to the next page. Instead of waiting + until all images up to the target page were loaded, prioritsation makes more recent + images to load first. + + Mostly copied from kivy.loader.Loader. + """ + def start(self): + LoaderBase.start(self) # mb: skip LoaderThreadPool.start + self.pool = _ThreadPool(self._num_workers) + Clock.schedule_interval(self.run, 0) + + def image(self, filename, load_callback=None, post_callback=None, + **kwargs): + data = Cache.get('kv.loader', filename) + if data not in (None, False): + # found image, if data is not here, need to reload. + return ProxyImage(data, + loading_image=self.loading_image, + loaded=True, **kwargs) + + client = ProxyImage(self.loading_image, + loading_image=self.loading_image, **kwargs) + self._client.append((filename, client)) + + if data is None: + # if data is None, this is really the first time + self._q_load.appendleft({ + 'filename': filename, + 'load_callback': load_callback, + 'post_callback': post_callback, + 'request_time': Clock.get_time(), # mb: also pass time of original request + 'kwargs': kwargs}) + if not kwargs.get('nocache', False): + Cache.append('kv.loader', filename, False) + self._start_wanted = True + self._trigger_update() + else: + # already queued for loading + pass + + return client + + def _clear(self): + if self.pool is not None: + tbr = set() + + # clear loader queue + while len(self._q_load): + kargs = self._q_load.pop() + tbr.add(kargs['filename']) + + # clear task queue + while not self.pool.tasks.empty(): + func, args, kargs = self.pool.tasks.get() + if len(args) and 'filename' in args[0]: + tbr.add(args[0]['filename']) + self.pool.tasks.task_done() + + # remove spurious entries from cache + for key in tbr: + # remove directly from Cache if _clear is run from the main thread + Cache.remove('kv.loader', key) + # otherwise go via _q_done + #self._q_done.appendleft(key, None, 0)) + + # remove spurious clients + for key in ((name, client) for name, client in self._client if name in tbr): + self._client.remove(key) + + def clear(self): + """Empty the queue without loading the images.""" + # execute in main thread + self._clear() + # schedule as event (no real benefit) + #if self.pool is not None: + # self.pool.add_task(self._clear) + + def _load(self, kwargs): + while len(self._q_done) >= ( + self.max_upload_per_frame * self._num_workers): + time.sleep(0.1) + + self._wait_for_resume() + + filename = kwargs['filename'] + load_callback = kwargs['load_callback'] + post_callback = kwargs['post_callback'] + try: + proto = filename.split(':', 1)[0] + except: + # if blank filename then return + return + if load_callback is not None: + data = load_callback(filename) + elif proto in ('http', 'https', 'ftp', 'smb'): + data = self._load_urllib(filename, kwargs['kwargs']) + else: + data = self._load_local(filename, kwargs['kwargs']) + + if post_callback: + data = post_callback(data) + + # mb: also pass request_time + self._q_done.appendleft((filename, data, kwargs['request_time'])) + self._trigger_update() + + def _update(self, *largs): + # want to start it ? + if self._start_wanted: + if not self._running: + self.start() + self._start_wanted = False + + # in pause mode, don't unqueue anything. + if self._paused: + self._trigger_update() + return + + for x in range(self.max_upload_per_frame): + try: + filename, data, timestamp = self._q_done.pop() + except IndexError: + return + + # create the image + image = data # ProxyImage(data) + + if image is None: # mb: discard items + # remove cache and client entries + Cache.remove('kv.loader', filename) + for key in ((name, client) for name, client in self._client if name == filename): + self._client.remove(key) + continue + + if not image.nocache: + Cache.append('kv.loader', filename, image) + # mb: fix cache times + Cache._objects['kv.loader'][filename]['lastaccess'] = timestamp + Cache._objects['kv.loader'][filename]['timestamp'] = timestamp + + # update client + for c_filename, client in self._client[:]: + if filename != c_filename: + continue + # got one client to update + client.image = image + client.loaded = True + client.dispatch('on_load') + self._client.remove((c_filename, client)) + + self._trigger_update() + +Loader = TagitImageLoader() + +## EOF ## diff --git a/tagit/widgets/session.py b/tagit/widgets/session.py new file mode 100644 index 0000000..30833b7 --- /dev/null +++ b/tagit/widgets/session.py @@ -0,0 +1,157 @@ +""" + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 +""" +# standard imports +from threading import current_thread +import typing + +# kivy imports +from kivy.clock import Clock +from kivy.uix.widget import Widget +import kivy.properties as kp + +# tagit imports +from tagit.config.loader import load_settings +#from tagit.storage.broker import Broker # FIXME: mb/port +#from tagit.storage.loader import load_broker, load_log # FIXME: mb/port + +# exports +__all__: typing.Sequence[str] = ( + 'ConfigAwareMixin', + 'Session', + ) + + +## code ## + +class Session(Widget): + storage = kp.ObjectProperty(None) + cfg = kp.ObjectProperty(None) + + __events__ = ('on_storage_modified', 'on_predicate_modified', 'on_config_changed') + + def __init__(self, cfg, storage, log, **kwargs): + super(Session, self).__init__(**kwargs) + self.cfg = cfg + self.storage = storage + self.log = log + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + pass + + def save(self): + """Save the session.""" + # save config and storage + if self.cfg.file_connected(): + self.cfg.diff(load_settings()).save() + + if self.storage.file_connected(): + self.storage.save() + + def clone(self, cfg): + """Clone the session and load the clone.""" + # clone storages to new location + liburi = cfg('session', 'paths', 'library') + numuri = cfg('session', 'paths', 'numerical') + storage = Broker.Clone(self.storage, liburi, numuri, None, cfg) + log = load_log(cfg) # not cloned + # switch to new storage + self.cfg = cfg + self.log = log + self.storage = storage + + def load(self, cfg): + """Load the session from configuration *cfg*.""" + self.cfg = cfg + # initialize storages from config + self.log = load_log(cfg) + self.storage = load_broker(cfg) + + def update_settings_key(self, key, value): + # change setting + self.cfg.set(key, value) + + # update settings file + # FIXME: file_connected is also true if it loaded config from user home! + if self.cfg.file_connected() and self.cfg('storage', 'config', 'write_through'): + # store only difference to baseline (i.e. session config) + local_config = self.cfg.diff(load_settings()) + local_config.save() + + # trigger update event + self.dispatch('on_config_changed', key, value) + + def on_config_changed(sender, key, value): + """Event prototype.""" + pass + + def on_storage(self, wx, storage): + # fire event if the storage was replaced + self.dispatch('on_storage_modified') + + def on_storage_modified(sender): + """Event prototype. + Triggered when items are added or removed + """ + pass + + def on_predicate_modified(sender, predicate, objects, diff): + """Event prototype. + Triggered when a predicate to one or several objects have been changed. + """ + pass + +class StorageAwareMixin(object): + def on_root(self, wx, root): + session = root.session + # storage has been changed as a whole + session.bind(storage=self.on_storage) + # some parts of the storage have changed + session.bind(on_storage_modified=self.on_storage_modified) + session.bind(on_predicate_modified=self.on_predicate_modified) + if session.storage is not None: + # initialize with the current storage + # Going through the event dispatcher ensures that the object + # is initialized properly before on_storage is called. + Clock.schedule_once(lambda dt: self.on_storage(session, session.storage)) + + def on_storage(self, sender, storage): + """Default event handler.""" + pass + + def on_storage_modified(self, sender): + """Default event handler.""" + pass + + def on_predicate_modified(self, sender, predicate, objects, diff): + """Default event handler.""" + pass + +class ConfigAwareMixin(object): + def on_root(self, wx, root): + session = root.session + # config changes as a whole + session.bind(cfg=self.on_cfg) + # individual config entries have been changed + session.bind(on_config_changed=self.on_config_changed) + if session.cfg is not None: + # initialize with the current config + # Going through the event dispatcher ensures that the object + # is initialized properly before on_cfg is called. + Clock.schedule_once(lambda dt: self.on_cfg(session, session.cfg)) + + def on_config_changed(self, sender, key, value): + """Default event handler.""" + pass + + def on_cfg(self, sender, cfg): + """Default event handler.""" + pass + +## EOF ## diff --git a/tagit/widgets/status.kv b/tagit/widgets/status.kv new file mode 100644 index 0000000..2d49b15 --- /dev/null +++ b/tagit/widgets/status.kv @@ -0,0 +1,59 @@ +#-- #:import ButtonDock tagit.widgets.dock.ButtonDock # FIXME: mb/port + +<Status>: + orientation: 'horizontal' + status: '' + navigation: '' + status_label: status_label + navigation_label: navigation_label + + ButtonDock: + root: root.root + orientation: 'lr-tb' + size_hint: None, 1 + name: 'navigation_left' + # space for three buttons + width: 3*30 + 2*5 + spacing: 5 + button_height: 30 + button_show: 'image', + + Label: + id: navigation_label + size_hint: None, 1 + width: 180 + markup: True + text: root.navigation + + ButtonDock: + root: root.root + size_hint: None, 1 + orientation: 'lr-tb' + name: 'navigation_right' + # space for three buttons + width: 3*30 + 2*5 + spacing: 5 + button_height: 30 + button_show: 'image', + + Label: + # gets remaining size + id: status_label + text_size: self.size + markup: True + valign: 'middle' + halign: 'left' + text: root.status + + ButtonDock: + root: root.root + orientation: 'lr-tb' + size_hint: None, 1 + name: 'status' + # space for three buttons + width: 3*30 + 2*5 + spacing: 5 + button_height: 30 + button_show: 'image', + +## EOF ## diff --git a/tagit/widgets/status.py b/tagit/widgets/status.py new file mode 100644 index 0000000..7b08eee --- /dev/null +++ b/tagit/widgets/status.py @@ -0,0 +1,209 @@ +"""Status line. + +Provides space for some buttons (typically navigation buttons), +information about the current viewport, and a status line. + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 +""" +# standard imports +import os +import logging + +# kivy imports +from kivy.clock import mainthread +from kivy.lang import Builder +from kivy.uix.boxlayout import BoxLayout +import kivy.properties as kp + +# tagit imports +from tagit import config +from tagit import dialogues +#from tagit.logger import CallbackHandler, logger_config # FIXME: mb/port +#from tagit.uix.kivy.colors import ColorsMarkup # FIXME: mb/port + +# inner-module imports +from .browser import BrowserAwareMixin +from .session import ConfigAwareMixin + +# exports +__all__ = ('Status', ) + + +## code ## + +# load kv +Builder.load_file(os.path.join(os.path.dirname(__file__), 'status.kv')) + +# classes +class Status(BoxLayout, BrowserAwareMixin, ConfigAwareMixin): + """Status line.""" + # root reference + root = kp.ObjectProperty(None) + # log history + history = kp.ListProperty() + # log handlers + handler_history = None + handler_status = None + + # events + + __events__ = ('on_status', ) + + def on_status(sender, status): + """Event prototype""" + pass + + + # bindings to others + + def on_root(self, wx, root): + """Bind events.""" + # bind to browser and config + BrowserAwareMixin.on_root(self, wx, root) + ConfigAwareMixin.on_root(self, wx, root) + # bind to status update event + self.bind(on_status=self.status_from_event) + + def on_browser(self, wx, browser): + """Bind to current browser properties.""" + # remove old binding + if self.browser is not None: + self.browser.unbind(page_size=self.on_navigation) + self.browser.unbind(items=self.on_navigation) + self.browser.unbind(offset=self.on_navigation) + # add new binding + self.browser = browser + if self.browser is not None: + self.browser.bind(page_size=self.on_navigation) + self.browser.bind(items=self.on_navigation) + self.browser.bind(offset=self.on_navigation) + self.on_navigation(browser, browser.offset) + + def on_config_changed(self, session, key, value): + if key in (('ui', 'standalone', 'logging', 'status'), + ('ui', 'standalone', 'logging', 'console')): + self.on_cfg(session, session.cfg) + + def on_cfg(self, wx, cfg): + """Register handlers according to config.""" + if self.handler_status is not None: + logging.getLogger().root.removeHandler(self.handler_status) + if self.handler_history is not None: + logging.getLogger().root.removeHandler(self.handler_history) + + # status log event + self.handler_status = logger_config( + CallbackHandler(self.status_from_log), + ColorsMarkup, + cfg('ui', 'standalone', 'logging', 'status').to_tree(defaults=True)) + logging.getLogger().root.addHandler(self.handler_status) + + # history (console) + self.handler_history = logger_config( + CallbackHandler(self.update_history), + ColorsMarkup, + cfg('ui', 'standalone', 'logging', 'console').to_tree(defaults=True)) + logging.getLogger().root.addHandler(self.handler_history) + + def __del__(self): + if self.browser is not None: + self.browser.unbind(page_size=self.on_navigation) + self.browser.unbind(items=self.on_navigation) + self.browser.unbind(offset=self.on_navigation) + self.browser = None + + if self.handler_status is not None: + logging.getLogger().root.removeHandler(self.handler_status) + self.handler_status = None + + if self.handler_history is not None: + logging.getLogger().root.removeHandler(self.handler_history) + self.handler_history = None + + + # console + + def on_touch_down(self, touch): + """Open console dialogue when clicked on the status label.""" + if self.status_label.collide_point(*touch.pos): + self.console() # show console + return True + elif self.navigation_label.collide_point(*touch.pos): + self.root.trigger('JumpToPage') # show page dialogue + return True + return super(Status, self).on_touch_down(touch) + + def console(self): + """Open console dialogue.""" + dlg = dialogues.Console() + self.bind(history=dlg.update) + dlg.update(self, self.history) + dlg.open() + + + # content updates + + def on_navigation(self, browser, value): + """Update the navigation label if the browser changes.""" + first = browser.offset + 1 # first on page + last = min(browser.offset + browser.page_size, browser.n_items) # last on page + total = browser.n_items # total results + self.navigation = f'{first} - {last} of {total}' + + @mainthread + def update_history(self, fmt, record): + """Update the history from the logger.""" + self.history.append(fmt(record)) + + def status_from_event(self, wx, status): + """Update the status line from the status event.""" + self.status = status + + @mainthread + def status_from_log(self, fmt, record): + """Update the status line from the logger.""" + self.status = fmt(record) + + +## config ## + +# status +config.declare(('ui', 'standalone', 'logging', 'status', 'level'), + config.Enum('debug', 'info', 'warn', 'error', 'critical'), 'info', + __name__, 'Log level', 'Maximal log level for which messages are shown. The order is: critical > error > warning > info > volatile > debug') + +config.declare(('ui', 'standalone', 'logging', 'status', 'filter'), + config.List(config.String()), ['tagit'], + __name__, 'Module filter', 'Module name for which log messages are accepted.') + +config.declare(('ui', 'standalone', 'logging', 'status', 'fmt'), config.String(), '{title}{message}', + __name__, 'Log format', 'Log message formatting. The formatting follows the typical python format string where each item is enclosed in curly braces (e.g. "{message}"). For printable items see the `logging attributes <https://docs.python.org/3.6/library/logging.html#logrecord-attributes>`_. In addition to those, the item "title" is available, which is a placeholder for the formatted title (if present).') + +config.declare(('ui', 'standalone', 'logging', 'status', 'title'), config.String(), '{title}: ', + __name__, 'Title format', 'Title formatting.') + +config.declare(('ui', 'standalone', 'logging', 'status', 'maxlen'), config.Unsigned(), 40, + __name__, 'Maximal line length', 'Maximal line length (e.g. console width). Use Infinity to set no line length limit.') + +# console +config.declare(('ui', 'standalone', 'logging', 'console', 'level'), + config.Enum('debug', 'info', 'warn', 'error', 'critical'), 'info', + __name__, 'Log level', 'Maximal log level for which messages are shown. The order is: critical > error > warning > info > volatile > debug') + +config.declare(('ui', 'standalone', 'logging', 'console', 'filter'), + config.List(config.String()), ['tagit'], + __name__, 'Module filter', 'Module name for which log messages are accepted.') + +config.declare(('ui', 'standalone', 'logging', 'console', 'fmt'), + config.String(), '[{levelname}] {title}{message}', + __name__, 'Log format', 'Log message formatting. The formatting follows the typical python format string where each item is enclosed in curly braces (e.g. "{message}"). For printable items see the `logging attributes <https://docs.python.org/3.6/library/logging.html#logrecord-attributes>`_. In addition to those, the item "title" is available, which is a placeholder for the formatted title (if present).') + +config.declare(('ui', 'standalone', 'logging', 'console', 'title'), config.String(), '[{title}]', + __name__, 'Title format', 'Title formatting.') + +config.declare(('ui', 'standalone', 'logging', 'console', 'maxlen'), config.Unsigned(), 0, + __name__, 'Maximal line length', 'Maximal line length (e.g. console width). Use zero or infinity to set no line length limit.') + +## EOF ## diff --git a/tagit/widgets/tabs.kv b/tagit/widgets/tabs.kv new file mode 100644 index 0000000..e206b1b --- /dev/null +++ b/tagit/widgets/tabs.kv @@ -0,0 +1,31 @@ +#:import Filter tagit.widgets.filter +#:import Browser tagit.widgets.browser + +<Tab>: + orientation: 'vertical' + size_hint: 1, 1 + # content + browser: browser + filter: filter + + Label: # activity indicator + size_hint: 1, 0.02 + canvas.before: + Color: + rgba: 0, 0, root.active, 1 + Rectangle: + pos: self.pos + size: self.size + + Filter: + id: filter + root: root.root + size_hint: 1, None + height: 30 + + Browser: + id: browser + root: root.root + size_hint: 1, 0.96 + +## EOF ## diff --git a/tagit/widgets/tabs.py b/tagit/widgets/tabs.py new file mode 100644 index 0000000..6fef276 --- /dev/null +++ b/tagit/widgets/tabs.py @@ -0,0 +1,37 @@ +""" + +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 +from kivy.uix.boxlayout import BoxLayout +import kivy.properties as kp + + +## code ## + +# load kv +Builder.load_file(os.path.join(os.path.dirname(__file__), 'tabs.kv')) + +# classes +class Tab(BoxLayout): + """A tab holds a filter and browser instance for side-by-side view. + All tabs are shown next to each other at all times. + """ + # root reference + root = kp.ObjectProperty(None) + # activity indicator + active = kp.BooleanProperty(False) + + def on_touch_down(self, touch): + """Switch to the present tab by clicking into it.""" + if self.collide_point(*touch.pos): + self.root.trigger('SwitchTab', self) + return super(Tab, self).on_touch_down(touch) + +## EOF ## |