From 0ba7a15c124d3a738a45247e78381dd56f7f1fa9 Mon Sep 17 00:00:00 2001 From: Matthias Baumgartner Date: Fri, 6 Jan 2023 12:14:51 +0100 Subject: desktop widget clone --- tagit/widgets/__init__.py | 10 ++ tagit/widgets/desktop.kv | 130 ++++++++++++++++++++++++ tagit/widgets/desktop.py | 254 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 394 insertions(+) create mode 100644 tagit/widgets/__init__.py create mode 100644 tagit/widgets/desktop.kv create mode 100644 tagit/widgets/desktop.py (limited to 'tagit/widgets') diff --git a/tagit/widgets/__init__.py b/tagit/widgets/__init__.py new file mode 100644 index 0000000..c3ec3c0 --- /dev/null +++ b/tagit/widgets/__init__.py @@ -0,0 +1,10 @@ +""" + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 +""" +# inner-module imports +from .desktop import MainWindow + +## EOF ## diff --git a/tagit/widgets/desktop.kv b/tagit/widgets/desktop.kv new file mode 100644 index 0000000..5d6c8f2 --- /dev/null +++ b/tagit/widgets/desktop.kv @@ -0,0 +1,130 @@ +#:import TileDecorationBorder tagit.uix.kivy.tiles.decoration.TileDecorationBorder +#:import TileDecorationFilledRectangle tagit.uix.kivy.tiles.decoration.TileDecorationFilledRectangle + +# DEBUG: Draw borders around all widgets +#: +# canvas.after: +# Line: +# rectangle: self.x+1,self.y+1,self.width-1,self.height-1 +# dash_offset: 5 +# dash_length: 3 + +: + # main content + tabs: tabs + # required by most tiles and actions + browser: tabs.children[tabs.current].browser + filter: tabs.children[tabs.current].filter + status: status + # required by actions.planes + planes: planes + # required by Menu + context: context + + Carousel: + id: planes + loop: True + scroll_timeout: 0 # disables switching by touch event + # plane references + dashboard: dashboard + browsing: browsing + codash: codash + + # planes + + TileDock: # static dashboard plane + id: dashboard + root: root + # plane config + size_hint: 1, 1 + visible: planes.current_slide == self + # dock config + name: 'dashboard' + decoration: TileDecorationBorder + cols: 3 + rows: 2 + # self.size won't be updated correctly + tile_width: self.width / self.cols + tile_height: self.height / self.rows + + BoxLayout: # browsing plane + id: browsing + orientation: 'horizontal' + visible: planes.current_slide == self + + ButtonDock: # one column of buttons on the left + root: root + orientation: 'tb-lr' + # one column of buttons + width: 30 + 2*10 + name: 'sidebar_left' + spacing: 10 + padding: 10 + size_hint: None, 1 + button_height: 30 + button_show: 'image', + + BoxLayout: # main content + orientation: 'vertical' + size_hint: 1, 1 + + BoxLayout: + id: tabs + orientation: 'horizontal' + size_hint: 1, 1 + current: 0 + + # Here come the browsing tabs + + Tab: + root: root + active: True + # one tab is always present + + Status: + id: status + root: root + size_hint: 1, None + height: 30 + + TileDock: # context info to the right + root: root + visible: planes.current_slide == self.parent + name: 'sidebar_right' + decoration: TileDecorationFilledRectangle + cols: 1 + rows: 3 + # self.height won't be updated correctly + #tile_height: self.size[1] / 4 + width: 180 + size_hint: None, 1 + + TileDock: # contextual dashboard + id: codash + root: root + # plane config + size_hint: 1, 1 + visible: planes.current_slide == self + # dock config + name: 'codash' + decoration: TileDecorationBorder + cols: 4 + rows: 2 + # self.size won't be update correctly + tile_width: self.width / 4 + tile_height: self.height / 2 + + Context: # context menu + id: context + root: root + cancel_handler_widget: root + bounding_box_widget: root + name: 'context' + + KeybindDock: + # key-only actions + root: root + size_hint: None, None + size: 0, 0 + +## EOF ## diff --git a/tagit/widgets/desktop.py b/tagit/widgets/desktop.py new file mode 100644 index 0000000..364c4ec --- /dev/null +++ b/tagit/widgets/desktop.py @@ -0,0 +1,254 @@ +"""Main container of the tagit UI. + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2016 + +""" +# 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 +from tagit import config +import tagit.uix.kivy.dialogues as dialogue +# tagit widget imports +from .actions import ActionBuilder +from .browser import Browser +from .context import Context +from .dock import TileDock, ButtonDock, KeybindDock +from .filter import Filter +from .keyboard import Keyboard +from .session import Session +from .status import Status +from .tabs import Tab + +# exports +__all__ = ('MainWindow', 'KIVY_IMAGE_CACHE_SIZE', 'KIVY_IMAGE_CACHE_TIMEOUT') + + +## code ## + +logger = logging.getLogger(__name__) + +# load kv +Builder.load_file(join(dirname(__file__), 'desktop.kv')) + +# classes +class MainWindow(FloatLayout): + """A self-contained user interface for desktop usage. + See `tagit.apps.gui` for an example of how to invoke it. + """ + + keys = kp.ObjectProperty(None) + + # unnecessary but nicely explicit + browser = kp.ObjectProperty(None) + filter = kp.ObjectProperty(None) + keytriggers = kp.ObjectProperty(None) + + # FIXME: log actions and and replay them + action_log = kp.ListProperty() + + def __init__ (self, cfg, stor, log, **kwargs): + # initialize the session + self._session = Session(cfg, stor, log) + # initialize key-only actions + self.keys = Keyboard() + + # initialize the cache + cache_size = max(0, cfg('ui', 'standalone', 'browser', 'cache_size')) + cache_size = cache_size if cache_size > 0 else None + cache_timeout = max(0, cfg('ui', 'standalone', 'browser', 'cache_timeout')) + cache_timeout = cache_timeout if cache_timeout > 0 else None + Cache.register('kv.loader', limit=cache_size, timeout=cache_timeout) + + # initialize the widget + super(MainWindow, self).__init__(**kwargs) + + # bind pre-close checks + from kivy.core.window import Window + Window.bind(on_request_close=self.on_request_close) + + + ## properties + + @property + def session(self): + return self._session + + def trigger(self, action, *args, **kwargs): + """Trigger an action once.""" + ActionBuilder().get(action).single_shot(self, *args, **kwargs) + + + ## functions + + def autoindex(self, *args): + self.trigger('AutoImport') + + def autoupdate(self, *args): + self.trigger('AutoUpdate') + + def autosync(self, *args): + self.trigger('AutoSync') + + def autosave(self, *args): + if not self.session.storage.file_connected(): + return + + try: + self.trigger('SaveLibrary') + logger.info('Database: Autosaved') + except Exception as e: + logger.error(f'Database: Autosave failed ({e})') + + + ## startup and shutdown + + def on_startup(self): + # start autosave + autosave = self.session.cfg('storage', 'library', 'autosave') + if autosave > 0: + # autosave is in minutes + Clock.schedule_interval(self.autosave, autosave * 60) + + # start index + autoindex = self.session.cfg('storage', 'index', 'autoindex') + autoindex = 0 if autoindex == float('inf') else autoindex + if autoindex > 0: + # autoindex is in minutes + Clock.schedule_interval(self.autoindex, autoindex * 60) + + # start update + autoupdate = self.session.cfg('storage', 'index', 'autoupdate') + autoupdate = 0 if autoupdate == float('inf') else autoupdate + if autoupdate > 0: + # autoupdate is in minutes + Clock.schedule_interval(self.autoupdate, autoupdate * 60) + + # start sync + autosync = self.session.cfg('storage', 'index', 'autosync') + autosync = 0 if autosync == float('inf') else autosync + if autosync > 0: + # autosync is in minutes + Clock.schedule_interval(self.autosync, autosync * 60) + + # trigger operations on startup + if self.session.cfg('storage', 'index', 'index_on_startup'): + self.autoindex() + + if self.session.cfg('storage', 'index', 'update_on_startup'): + self.autoupdate() + + if self.session.cfg('storage', 'index', 'sync_on_startup'): + self.autosync() + + # switch to starting plane - if it's the dashboard no action is needed + if self.session.cfg('ui', 'standalone', 'plane') == 'browsing': + self.trigger('ShowBrowsing') + + # show welcome message + if self.session.cfg('session', 'first_start'): + self.display_welcome() + + # script + return + Clock.schedule_once(lambda dt: self.trigger('Search'), 0) + Clock.schedule_once(lambda dt: self.trigger('MoveCursorFirst'), 0) + Clock.schedule_once(lambda dt: self.trigger('SortKey', 'fid:image.colors_spatial:726b4e8ea45546e55dfcd4216b276284'), 0) + from kivy.app import App + App.get_running_app().stop() + Clock.schedule_once(lambda dt: self.trigger('ZoomOut'), 0) + Clock.schedule_once(lambda dt: self.trigger('ZoomOut'), 0) + Clock.schedule_once(lambda dt: self.trigger('NextPage'), 0) + Clock.schedule_once(lambda dt: self.trigger('NextPage'), 0) + Clock.schedule_once(lambda dt: self.trigger('NextPage'), 0) + Clock.schedule_once(lambda dt: self.trigger('NextPage'), 0) + Clock.schedule_once(lambda dt: self.trigger('NextPage'), 0) + Clock.schedule_once(lambda dt: self.trigger('NextPage'), 0) + Clock.schedule_once(lambda dt: self.trigger('NextPage'), 0) + Clock.schedule_once(lambda dt: self.trigger('NextPage'), 0) + Clock.schedule_once(lambda dt: self.trigger('NextPage'), 0) + Clock.schedule_once(lambda dt: self.trigger('NextPage'), 0) + Clock.schedule_once(lambda dt: self.trigger('NextPage'), 0) + Clock.schedule_once(lambda dt: self.trigger('NextPage'), 0) + Clock.schedule_once(lambda dt: self.trigger('NextPage'), 0) + Clock.schedule_once(lambda dt: self.trigger('NextPage'), 0) + Clock.schedule_once(lambda dt: self.trigger('NextPage'), 0) + Clock.schedule_once(lambda dt: self.trigger('NextPage'), 0) + Clock.schedule_once(lambda dt: self.trigger('NextPage'), 0) + Clock.schedule_once(lambda dt: self.trigger('NextPage'), 0) + Clock.schedule_once(lambda dt: self.trigger('NextPage'), 0) + Clock.schedule_once(lambda dt: self.trigger('NextPage'), 0) + Clock.schedule_once(lambda dt: self.trigger('NextPage'), 0) + #from kivy.app import App + #App.get_running_app().stop() + + def on_request_close(self, *args): + + with open('.action_history', 'a') as ofile: + for itm in self.action_log: + ofile.write(f'{itm}\n') + + if self.session.storage.changed() and not self.session.cfg('session', 'debug'): + # save and close + self.trigger('CloseSessionAndExit') + return True + # close w/o saving + return False + + def display_welcome(self): + """Display a welcome dialogue on the first start.""" + message = """ +[size=20sp]Welcome to [b]tagit[/b]![/size] + +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() + + +## config ## + +config.declare(('storage', 'library', 'autosave'), config.Float(), 0, + __name__, 'Autosave', 'Time interval in minutes at which the library is saved to disk while running the GUI. A value of 0 means that the feature is disabled.') + +config.declare(('storage', 'index', 'autoindex'), config.Float(), 0, + __name__, 'Autoindex', 'Time interval in minutes at which indexing is triggered while running the GUI. A value of 0 means that the feature is disabled. Also configure the index watchlist.') + +config.declare(('storage', 'index', 'autoupdate'), config.Float(), 0, + __name__, 'Autoupdate', 'Time interval in minutes at which updating is triggered while running the GUI. A value of 0 means that the feature is disabled.') + +config.declare(('storage', 'index', 'autosync'), config.Float(), 0, + __name__, 'Autosync', 'Time interval in minutes at which synchronization is triggered while running the GUI. A value of 0 means that the feature is disabled.') + +config.declare(('storage', 'index', 'index_on_startup'), config.Bool(), False, + __name__, 'Index on startup', 'Trigger indexing when the GUI is started. Also configure the index watchlist') + +config.declare(('storage', 'index', 'update_on_startup'), config.Bool(), False, + __name__, 'Update on startup', 'Trigger updating when the GUI is started.') + +config.declare(('storage', 'index', 'sync_on_startup'), config.Bool(), False, + __name__, 'Sync on startup', 'Trigger synchronization when the GUI is started.') + +config.declare(('session', 'first_start'), config.Bool(), True, + __name__, 'First start', 'Show the welcome message typically shown when tagit is started the first time.') + +config.declare(('ui', 'standalone', 'plane'), config.Enum('browsing', 'dashboard'), 'dashboard', + __name__, 'Initial plane', 'Start with the dashboard or browsing plane.') + +config.declare(('ui', 'standalone', 'browser', 'cache_size'), config.Unsigned(), 1000, + __name__, 'Cache size', 'Number of preview images that are held in the cache. Should be high or zero if memory is not an issue. Set to a small value to preserve memory, but should be at least the most common page size. It is advised to set a value in accordance with `ui.standalone.browser.cache_items`. If zero, no limit applies.') + +config.declare(('ui', 'standalone', 'browser', 'cache_timeout'), config.Unsigned(), 0, + __name__, 'Cache timeout', 'Number of seconds until cached items are discarded. Should be high or zero if memory is not an issue. Set it to a small value to preserve memory when browsing through many images. If zero, no limit applies. Specify in seconds.') + +## EOF ## -- cgit v1.2.3 From ad49aedaad3acece200ea92fd5d5a5b3e19c143b Mon Sep 17 00:00:00 2001 From: Matthias Baumgartner Date: Fri, 6 Jan 2023 14:07:15 +0100 Subject: desktop dependent widgets early port --- tagit/widgets/__init__.py | 1 + tagit/widgets/bindings.py | 278 +++++++++++++++++++ tagit/widgets/browser.kv | 100 +++++++ tagit/widgets/browser.py | 677 ++++++++++++++++++++++++++++++++++++++++++++++ tagit/widgets/context.kv | 25 ++ tagit/widgets/context.py | 148 ++++++++++ tagit/widgets/desktop.kv | 4 +- tagit/widgets/desktop.py | 32 ++- tagit/widgets/dock.kv | 20 ++ tagit/widgets/dock.py | 239 ++++++++++++++++ tagit/widgets/filter.kv | 83 ++++++ tagit/widgets/filter.py | 301 +++++++++++++++++++++ tagit/widgets/keyboard.py | 142 ++++++++++ tagit/widgets/loader.py | 200 ++++++++++++++ tagit/widgets/session.py | 157 +++++++++++ tagit/widgets/status.kv | 59 ++++ tagit/widgets/status.py | 209 ++++++++++++++ tagit/widgets/tabs.kv | 31 +++ tagit/widgets/tabs.py | 37 +++ 19 files changed, 2729 insertions(+), 14 deletions(-) create mode 100644 tagit/widgets/bindings.py create mode 100644 tagit/widgets/browser.kv create mode 100644 tagit/widgets/browser.py create mode 100644 tagit/widgets/context.kv create mode 100644 tagit/widgets/context.py create mode 100644 tagit/widgets/dock.kv create mode 100644 tagit/widgets/dock.py create mode 100644 tagit/widgets/filter.kv create mode 100644 tagit/widgets/filter.py create mode 100644 tagit/widgets/keyboard.py create mode 100644 tagit/widgets/loader.py create mode 100644 tagit/widgets/session.py create mode 100644 tagit/widgets/status.kv create mode 100644 tagit/widgets/status.py create mode 100644 tagit/widgets/tabs.kv create mode 100644 tagit/widgets/tabs.py (limited to 'tagit/widgets') 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 + +: + root: None + spacing: 10 + size_hint: 1.0, 1.0 + page_size: self.cols * self.rows + # must not define rows and cols + +: + 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 + + +: # 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', + +: # 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 + +: + 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 + +: + 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 + +: + 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 #: 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 + +: + cols: 3 + rows: 3 + decoration: TileDecorationVanilla + visible: False + tile_height: None + tile_width: None + name: '' + +: + 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 + +: + 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', + +: + 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() + +: + 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 + +: + 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 `_. 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 `_. 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 + +: + 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 ## -- cgit v1.2.3 From ceaaef069d8ffda23fce320ce66c86e0226f1046 Mon Sep 17 00:00:00 2001 From: Matthias Baumgartner Date: Fri, 6 Jan 2023 17:40:25 +0100 Subject: first startup, empty screen --- tagit/widgets/browser.kv | 2 +- tagit/widgets/browser.py | 1 + tagit/widgets/desktop.py | 1 + tagit/widgets/filter.kv | 1 + tagit/widgets/filter.py | 2 +- tagit/widgets/status.py | 1 + 6 files changed, 6 insertions(+), 2 deletions(-) (limited to 'tagit/widgets') diff --git a/tagit/widgets/browser.kv b/tagit/widgets/browser.kv index ed40a44..758d08f 100644 --- a/tagit/widgets/browser.kv +++ b/tagit/widgets/browser.kv @@ -1,4 +1,4 @@ -#:import OpenGroup tagit.actions.grouping +#-- #:import OpenGroup tagit.actions.grouping : root: None diff --git a/tagit/widgets/browser.py b/tagit/widgets/browser.py index df1a8b8..dace58b 100644 --- a/tagit/widgets/browser.py +++ b/tagit/widgets/browser.py @@ -394,6 +394,7 @@ class Browser(GridLayout, StorageAwareMixin, ConfigAwareMixin): # load previews for items # FIXME: Only relevant items, not all of them + return # FIXME: mb/port 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): diff --git a/tagit/widgets/desktop.py b/tagit/widgets/desktop.py index f012fc7..018bd60 100644 --- a/tagit/widgets/desktop.py +++ b/tagit/widgets/desktop.py @@ -207,6 +207,7 @@ class MainWindow(FloatLayout): for itm in self.action_log: ofile.write(f'{itm}\n') + return False # FIXME: mb/port if self.session.storage.changed() and not self.session.cfg('session', 'debug'): # save and close self.trigger('CloseSessionAndExit') diff --git a/tagit/widgets/filter.kv b/tagit/widgets/filter.kv index d98b5a7..b638570 100644 --- a/tagit/widgets/filter.kv +++ b/tagit/widgets/filter.kv @@ -1,4 +1,5 @@ #:import SearchmodeSwitch tagit.actions.filter +#-- #:import SortKey tagit.actions.search : root: None diff --git a/tagit/widgets/filter.py b/tagit/widgets/filter.py index 56d460a..0152737 100644 --- a/tagit/widgets/filter.py +++ b/tagit/widgets/filter.py @@ -101,7 +101,7 @@ class Filter(BoxLayout, ConfigAwareMixin): # sort #sortkey = kp.ObjectProperty(partial(ast.NumericalSort, 'time')) - sortkey = kp.ObjectProperty() # FIXME: mb/port + sortkey = kp.ObjectProperty(None) # FIXME: mb/port sortdir = kp.BooleanProperty(False) # False means ascending diff --git a/tagit/widgets/status.py b/tagit/widgets/status.py index 7b08eee..fea52b9 100644 --- a/tagit/widgets/status.py +++ b/tagit/widgets/status.py @@ -94,6 +94,7 @@ class Status(BoxLayout, BrowserAwareMixin, ConfigAwareMixin): logging.getLogger().root.removeHandler(self.handler_history) # status log event + return # FIXME: mb/port self.handler_status = logger_config( CallbackHandler(self.status_from_log), ColorsMarkup, -- cgit v1.2.3 From 6b6495b8f5b3bfd8fbd4caf56a44424df070e813 Mon Sep 17 00:00:00 2001 From: Matthias Baumgartner Date: Sat, 7 Jan 2023 15:53:20 +0100 Subject: removed tabs --- tagit/widgets/desktop.kv | 24 +++++++++++++++--------- tagit/widgets/desktop.py | 1 - tagit/widgets/tabs.kv | 31 ------------------------------- tagit/widgets/tabs.py | 37 ------------------------------------- 4 files changed, 15 insertions(+), 78 deletions(-) delete mode 100644 tagit/widgets/tabs.kv delete mode 100644 tagit/widgets/tabs.py (limited to 'tagit/widgets') diff --git a/tagit/widgets/desktop.kv b/tagit/widgets/desktop.kv index 9ebd08d..cbc5c48 100644 --- a/tagit/widgets/desktop.kv +++ b/tagit/widgets/desktop.kv @@ -11,10 +11,9 @@ : # main content - tabs: tabs # required by most tiles and actions - browser: tabs.children[tabs.current].browser - filter: tabs.children[tabs.current].filter + browser: browser + filter: filter status: status # required by actions.planes planes: planes @@ -69,17 +68,24 @@ size_hint: 1, 1 BoxLayout: - id: tabs orientation: 'horizontal' size_hint: 1, 1 current: 0 - # Here come the browsing tabs + BoxLayout: + orientation: 'vertical' + size_hint: 1, 1 - Tab: - root: root - active: True - # one tab is always present + Filter: + id: filter + root: root + size_hint: 1, None + height: 30 + + Browser: + id: browser + root: root + size_hint: 1, 0.96 Status: id: status diff --git a/tagit/widgets/desktop.py b/tagit/widgets/desktop.py index 018bd60..dffc3d7 100644 --- a/tagit/widgets/desktop.py +++ b/tagit/widgets/desktop.py @@ -33,7 +33,6 @@ from .filter import Filter from .keyboard import Keyboard from .session import Session from .status import Status -from .tabs import Tab # exports __all__: typing.Sequence[str] = ( diff --git a/tagit/widgets/tabs.kv b/tagit/widgets/tabs.kv deleted file mode 100644 index e206b1b..0000000 --- a/tagit/widgets/tabs.kv +++ /dev/null @@ -1,31 +0,0 @@ -#:import Filter tagit.widgets.filter -#:import Browser tagit.widgets.browser - -: - 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 deleted file mode 100644 index 6fef276..0000000 --- a/tagit/widgets/tabs.py +++ /dev/null @@ -1,37 +0,0 @@ -""" - -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 ## -- cgit v1.2.3 From 7cf03c4de5244e7db3f44362a275f92368fd86ac Mon Sep 17 00:00:00 2001 From: Matthias Baumgartner Date: Sun, 8 Jan 2023 14:08:38 +0100 Subject: moved desktop widgets to windows --- tagit/widgets/__init__.py | 1 - tagit/widgets/desktop.kv | 136 ------------------------ tagit/widgets/desktop.py | 262 ---------------------------------------------- 3 files changed, 399 deletions(-) delete mode 100644 tagit/widgets/desktop.kv delete mode 100644 tagit/widgets/desktop.py (limited to 'tagit/widgets') diff --git a/tagit/widgets/__init__.py b/tagit/widgets/__init__.py index 3892a22..2899f85 100644 --- a/tagit/widgets/__init__.py +++ b/tagit/widgets/__init__.py @@ -6,6 +6,5 @@ Author: Matthias Baumgartner, 2022 """ # inner-module imports from .bindings import Binding -from .desktop import MainWindow ## EOF ## diff --git a/tagit/widgets/desktop.kv b/tagit/widgets/desktop.kv deleted file mode 100644 index cbc5c48..0000000 --- a/tagit/widgets/desktop.kv +++ /dev/null @@ -1,136 +0,0 @@ -#:import TileDecorationBorder tagit.tiles.decoration.TileDecorationBorder -#:import TileDecorationFilledRectangle tagit.tiles.decoration.TileDecorationFilledRectangle - -# DEBUG: Draw borders around all widgets -#: -# canvas.after: -# Line: -# rectangle: self.x+1,self.y+1,self.width-1,self.height-1 -# dash_offset: 5 -# dash_length: 3 - -: - # main content - # required by most tiles and actions - browser: browser - filter: filter - status: status - # required by actions.planes - planes: planes - # required by Menu - context: context - - Carousel: - id: planes - loop: True - scroll_timeout: 0 # disables switching by touch event - # plane references - dashboard: dashboard - browsing: browsing - codash: codash - - # planes - - TileDock: # static dashboard plane - id: dashboard - root: root - # plane config - size_hint: 1, 1 - visible: planes.current_slide == self - # dock config - name: 'dashboard' - decoration: TileDecorationBorder - cols: 3 - rows: 2 - # self.size won't be updated correctly - tile_width: self.width / self.cols - tile_height: self.height / self.rows - - BoxLayout: # browsing plane - id: browsing - orientation: 'horizontal' - visible: planes.current_slide == self - - ButtonDock: # one column of buttons on the left - root: root - orientation: 'tb-lr' - # one column of buttons - width: 30 + 2*10 - name: 'sidebar_left' - spacing: 10 - padding: 10 - size_hint: None, 1 - button_height: 30 - button_show: 'image', - - BoxLayout: # main content - orientation: 'vertical' - size_hint: 1, 1 - - BoxLayout: - orientation: 'horizontal' - size_hint: 1, 1 - current: 0 - - BoxLayout: - orientation: 'vertical' - size_hint: 1, 1 - - Filter: - id: filter - root: root - size_hint: 1, None - height: 30 - - Browser: - id: browser - root: root - size_hint: 1, 0.96 - - Status: - id: status - root: root - size_hint: 1, None - height: 30 - - TileDock: # context info to the right - root: root - visible: planes.current_slide == self.parent - name: 'sidebar_right' - decoration: TileDecorationFilledRectangle - cols: 1 - rows: 3 - # self.height won't be updated correctly - #tile_height: self.size[1] / 4 - width: 180 - size_hint: None, 1 - - TileDock: # contextual dashboard - id: codash - root: root - # plane config - size_hint: 1, 1 - visible: planes.current_slide == self - # dock config - name: 'codash' - decoration: TileDecorationBorder - cols: 4 - rows: 2 - # self.size won't be update correctly - tile_width: self.width / 4 - tile_height: self.height / 2 - - Context: # context menu - id: context - root: root - cancel_handler_widget: root - bounding_box_widget: root - name: 'context' - - KeybindDock: - # key-only actions - root: root - size_hint: None, None - size: 0, 0 - -## EOF ## diff --git a/tagit/widgets/desktop.py b/tagit/widgets/desktop.py deleted file mode 100644 index dffc3d7..0000000 --- a/tagit/widgets/desktop.py +++ /dev/null @@ -1,262 +0,0 @@ -"""Main container of the tagit UI. - -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 typing - -# kivy imports -from kivy.clock import Clock -from kivy.lang import Builder -from kivy.uix.floatlayout import FloatLayout -import kivy.properties as kp - -# import Image and Loader to overwrite their caches later on -from kivy.loader import Loader -from kivy.cache import Cache - -# tagit imports -from tagit import actions -from tagit import config -from tagit import dialogues - -# inner-module imports -from .browser import Browser -from .context import Context -from .dock import TileDock, ButtonDock, KeybindDock -from .filter import Filter -from .keyboard import Keyboard -from .session import Session -from .status import Status - -# exports -__all__: typing.Sequence[str] = ( - 'KIVY_IMAGE_CACHE_SIZE', - 'KIVY_IMAGE_CACHE_TIMEOUT', - 'MainWindow', - ) - - -## code ## - -logger = logging.getLogger(__name__) - -# load kv -Builder.load_file(os.path.join(os.path.dirname(__file__), 'desktop.kv')) - -# classes -class MainWindow(FloatLayout): - """A self-contained user interface for desktop usage. - See `tagit.apps.gui` for an example of how to invoke it. - """ - - keys = kp.ObjectProperty(None) - - # unnecessary but nicely explicit - browser = kp.ObjectProperty(None) - filter = kp.ObjectProperty(None) - keytriggers = kp.ObjectProperty(None) - - # FIXME: log actions and and replay them - action_log = kp.ListProperty() - - def __init__ (self, cfg, stor, log, **kwargs): - # initialize the session - self._session = Session(cfg, stor, log) - # initialize key-only actions - self.keys = Keyboard() - - # initialize the cache - cache_size = max(0, cfg('ui', 'standalone', 'browser', 'cache_size')) - cache_size = cache_size if cache_size > 0 else None - cache_timeout = max(0, cfg('ui', 'standalone', 'browser', 'cache_timeout')) - cache_timeout = cache_timeout if cache_timeout > 0 else None - Cache.register('kv.loader', limit=cache_size, timeout=cache_timeout) - - # initialize the widget - super(MainWindow, self).__init__(**kwargs) - - # bind pre-close checks - from kivy.core.window import Window - Window.bind(on_request_close=self.on_request_close) - - - ## properties - - @property - def session(self): - return self._session - - def trigger(self, action, *args, **kwargs): - """Trigger an action once.""" - actions.ActionBuilder().get(action).single_shot(self, *args, **kwargs) - - - ## functions - - def autoindex(self, *args): - self.trigger('AutoImport') - - def autoupdate(self, *args): - self.trigger('AutoUpdate') - - def autosync(self, *args): - self.trigger('AutoSync') - - def autosave(self, *args): - if not self.session.storage.file_connected(): - return - - try: - self.trigger('SaveLibrary') - logger.info('Database: Autosaved') - except Exception as e: - logger.error(f'Database: Autosave failed ({e})') - - - ## startup and shutdown - - def on_startup(self): - # start autosave - autosave = self.session.cfg('storage', 'library', 'autosave') - if autosave > 0: - # autosave is in minutes - Clock.schedule_interval(self.autosave, autosave * 60) - - # start index - autoindex = self.session.cfg('storage', 'index', 'autoindex') - autoindex = 0 if autoindex == float('inf') else autoindex - if autoindex > 0: - # autoindex is in minutes - Clock.schedule_interval(self.autoindex, autoindex * 60) - - # start update - autoupdate = self.session.cfg('storage', 'index', 'autoupdate') - autoupdate = 0 if autoupdate == float('inf') else autoupdate - if autoupdate > 0: - # autoupdate is in minutes - Clock.schedule_interval(self.autoupdate, autoupdate * 60) - - # start sync - autosync = self.session.cfg('storage', 'index', 'autosync') - autosync = 0 if autosync == float('inf') else autosync - if autosync > 0: - # autosync is in minutes - Clock.schedule_interval(self.autosync, autosync * 60) - - # trigger operations on startup - if self.session.cfg('storage', 'index', 'index_on_startup'): - self.autoindex() - - if self.session.cfg('storage', 'index', 'update_on_startup'): - self.autoupdate() - - if self.session.cfg('storage', 'index', 'sync_on_startup'): - self.autosync() - - # switch to starting plane - if it's the dashboard no action is needed - if self.session.cfg('ui', 'standalone', 'plane') == 'browsing': - self.trigger('ShowBrowsing') - - # show welcome message - if self.session.cfg('session', 'first_start'): - self.display_welcome() - - # script - return - Clock.schedule_once(lambda dt: self.trigger('Search'), 0) - Clock.schedule_once(lambda dt: self.trigger('MoveCursorFirst'), 0) - Clock.schedule_once(lambda dt: self.trigger('SortKey', 'fid:image.colors_spatial:726b4e8ea45546e55dfcd4216b276284'), 0) - from kivy.app import App - App.get_running_app().stop() - Clock.schedule_once(lambda dt: self.trigger('ZoomOut'), 0) - Clock.schedule_once(lambda dt: self.trigger('ZoomOut'), 0) - Clock.schedule_once(lambda dt: self.trigger('NextPage'), 0) - Clock.schedule_once(lambda dt: self.trigger('NextPage'), 0) - Clock.schedule_once(lambda dt: self.trigger('NextPage'), 0) - Clock.schedule_once(lambda dt: self.trigger('NextPage'), 0) - Clock.schedule_once(lambda dt: self.trigger('NextPage'), 0) - Clock.schedule_once(lambda dt: self.trigger('NextPage'), 0) - Clock.schedule_once(lambda dt: self.trigger('NextPage'), 0) - Clock.schedule_once(lambda dt: self.trigger('NextPage'), 0) - Clock.schedule_once(lambda dt: self.trigger('NextPage'), 0) - Clock.schedule_once(lambda dt: self.trigger('NextPage'), 0) - Clock.schedule_once(lambda dt: self.trigger('NextPage'), 0) - Clock.schedule_once(lambda dt: self.trigger('NextPage'), 0) - Clock.schedule_once(lambda dt: self.trigger('NextPage'), 0) - Clock.schedule_once(lambda dt: self.trigger('NextPage'), 0) - Clock.schedule_once(lambda dt: self.trigger('NextPage'), 0) - Clock.schedule_once(lambda dt: self.trigger('NextPage'), 0) - Clock.schedule_once(lambda dt: self.trigger('NextPage'), 0) - Clock.schedule_once(lambda dt: self.trigger('NextPage'), 0) - Clock.schedule_once(lambda dt: self.trigger('NextPage'), 0) - Clock.schedule_once(lambda dt: self.trigger('NextPage'), 0) - Clock.schedule_once(lambda dt: self.trigger('NextPage'), 0) - #from kivy.app import App - #App.get_running_app().stop() - - def on_request_close(self, *args): - - with open('.action_history', 'a') as ofile: - for itm in self.action_log: - ofile.write(f'{itm}\n') - - return False # FIXME: mb/port - if self.session.storage.changed() and not self.session.cfg('session', 'debug'): - # save and close - self.trigger('CloseSessionAndExit') - return True - # close w/o saving - return False - - def display_welcome(self): - """Display a welcome dialogue on the first start.""" - message = """ -[size=20sp]Welcome to [b]tagit[/b]![/size] - -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! - dialogues.Message(text=message, align='left').open() - - -## config ## - -config.declare(('storage', 'library', 'autosave'), config.Float(), 0, - __name__, 'Autosave', 'Time interval in minutes at which the library is saved to disk while running the GUI. A value of 0 means that the feature is disabled.') - -config.declare(('storage', 'index', 'autoindex'), config.Float(), 0, - __name__, 'Autoindex', 'Time interval in minutes at which indexing is triggered while running the GUI. A value of 0 means that the feature is disabled. Also configure the index watchlist.') - -config.declare(('storage', 'index', 'autoupdate'), config.Float(), 0, - __name__, 'Autoupdate', 'Time interval in minutes at which updating is triggered while running the GUI. A value of 0 means that the feature is disabled.') - -config.declare(('storage', 'index', 'autosync'), config.Float(), 0, - __name__, 'Autosync', 'Time interval in minutes at which synchronization is triggered while running the GUI. A value of 0 means that the feature is disabled.') - -config.declare(('storage', 'index', 'index_on_startup'), config.Bool(), False, - __name__, 'Index on startup', 'Trigger indexing when the GUI is started. Also configure the index watchlist') - -config.declare(('storage', 'index', 'update_on_startup'), config.Bool(), False, - __name__, 'Update on startup', 'Trigger updating when the GUI is started.') - -config.declare(('storage', 'index', 'sync_on_startup'), config.Bool(), False, - __name__, 'Sync on startup', 'Trigger synchronization when the GUI is started.') - -config.declare(('session', 'first_start'), config.Bool(), True, - __name__, 'First start', 'Show the welcome message typically shown when tagit is started the first time.') - -config.declare(('ui', 'standalone', 'plane'), config.Enum('browsing', 'dashboard'), 'dashboard', - __name__, 'Initial plane', 'Start with the dashboard or browsing plane.') - -config.declare(('ui', 'standalone', 'browser', 'cache_size'), config.Unsigned(), 1000, - __name__, 'Cache size', 'Number of preview images that are held in the cache. Should be high or zero if memory is not an issue. Set to a small value to preserve memory, but should be at least the most common page size. It is advised to set a value in accordance with `ui.standalone.browser.cache_items`. If zero, no limit applies.') - -config.declare(('ui', 'standalone', 'browser', 'cache_timeout'), config.Unsigned(), 0, - __name__, 'Cache timeout', 'Number of seconds until cached items are discarded. Should be high or zero if memory is not an issue. Set it to a small value to preserve memory when browsing through many images. If zero, no limit applies. Specify in seconds.') - -## EOF ## -- cgit v1.2.3 From 8f2f697f7ed52b7e1c7a17411b2de526b6490691 Mon Sep 17 00:00:00 2001 From: Matthias Baumgartner Date: Thu, 12 Jan 2023 17:18:43 +0100 Subject: removed save and sync functionality since they no longer apply --- tagit/widgets/session.py | 9 --------- 1 file changed, 9 deletions(-) (limited to 'tagit/widgets') diff --git a/tagit/widgets/session.py b/tagit/widgets/session.py index 30833b7..a7c7355 100644 --- a/tagit/widgets/session.py +++ b/tagit/widgets/session.py @@ -45,15 +45,6 @@ class Session(Widget): 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 -- cgit v1.2.3 From 9c366758665d9cfee7796ee45a8167a5412ae9ae Mon Sep 17 00:00:00 2001 From: Matthias Baumgartner Date: Fri, 13 Jan 2023 09:49:10 +0100 Subject: filter early port, parsing adaptions --- tagit/widgets/filter.py | 13 ++++++++++--- tagit/widgets/session.py | 4 ++++ 2 files changed, 14 insertions(+), 3 deletions(-) (limited to 'tagit/widgets') diff --git a/tagit/widgets/filter.py b/tagit/widgets/filter.py index 0152737..332ad34 100644 --- a/tagit/widgets/filter.py +++ b/tagit/widgets/filter.py @@ -23,7 +23,7 @@ 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 +from tagit.utils import bsfs, errors # inner-module imports from .session import ConfigAwareMixin @@ -108,6 +108,10 @@ class Filter(BoxLayout, ConfigAwareMixin): ## exposed methods def get_query(self): + query = bsfs.ast.filter.And(self.t_head[:]) if len(self.t_head) > 0 else None + sort = None + return query, sort + # FIXME: mb/port.parsing query = ast.AND(self.t_head[:]) if len(self.t_head) else None # sort order is always set to False so that changing the sort order # won't trigger a new query which can be very expensive. The sort @@ -116,6 +120,8 @@ class Filter(BoxLayout, ConfigAwareMixin): return query, sort def abbreviate(self, token): + return 'T' + # FIXME: mb/port/parsing if token.predicate() == 'tag': return ','.join(list(token.condition())) elif token.predicate() == 'entity': @@ -162,8 +168,9 @@ class Filter(BoxLayout, ConfigAwareMixin): if self.changed: self.redraw() # issue search - if self.run_search: - self.root.trigger('Search') + # FIXME: mb/port/parsing + #if self.run_search: + # self.root.trigger('Search') def redraw(self): self.tokens.clear_widgets() diff --git a/tagit/widgets/session.py b/tagit/widgets/session.py index a7c7355..ca8c595 100644 --- a/tagit/widgets/session.py +++ b/tagit/widgets/session.py @@ -14,6 +14,7 @@ from kivy.uix.widget import Widget import kivy.properties as kp # tagit imports +from tagit import parsing from tagit.config.loader import load_settings #from tagit.storage.broker import Broker # FIXME: mb/port #from tagit.storage.loader import load_broker, load_log # FIXME: mb/port @@ -38,6 +39,9 @@ class Session(Widget): self.cfg = cfg self.storage = storage self.log = log + # derived members + self.filter_from_string = parsing.Filter(self.storage.schema) + #self.sort_from_string = parsing.Sort(self.storage.schema) # FIXME: mb/port/parsing def __enter__(self): return self -- cgit v1.2.3 From 52fa64513dae60c3ed410622502f8c2369c1a348 Mon Sep 17 00:00:00 2001 From: Matthias Baumgartner Date: Fri, 13 Jan 2023 10:14:18 +0100 Subject: moved filter parsing code --- tagit/widgets/session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'tagit/widgets') diff --git a/tagit/widgets/session.py b/tagit/widgets/session.py index ca8c595..f45ab35 100644 --- a/tagit/widgets/session.py +++ b/tagit/widgets/session.py @@ -40,7 +40,7 @@ class Session(Widget): self.storage = storage self.log = log # derived members - self.filter_from_string = parsing.Filter(self.storage.schema) + self.filter_from_string = parsing.filter.FromString(self.storage.schema) #self.sort_from_string = parsing.Sort(self.storage.schema) # FIXME: mb/port/parsing def __enter__(self): -- cgit v1.2.3 From 20d31b0c4a61b5f026fc8b0ff98c43b8a00bee48 Mon Sep 17 00:00:00 2001 From: Matthias Baumgartner Date: Wed, 25 Jan 2023 10:38:00 +0100 Subject: browser folding --- tagit/widgets/browser.kv | 39 ++++++++-------- tagit/widgets/browser.py | 116 +++++++++++++++++++++++++++++------------------ 2 files changed, 92 insertions(+), 63 deletions(-) (limited to 'tagit/widgets') diff --git a/tagit/widgets/browser.kv b/tagit/widgets/browser.kv index 758d08f..8b4c8c3 100644 --- a/tagit/widgets/browser.kv +++ b/tagit/widgets/browser.kv @@ -37,25 +37,26 @@ 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', + # FIXME: mb/port + #OpenGroup: + # root: root.browser.root + # # positioning: + # # (1) top right corner of the root (inside root) + # #x: root.width - self.width + # #y: root.height - self.height + # # (2) top right corner of the root (inside root) + # #pos_hint: {'top': 1.0, 'right': 1.0} + # # (3) top right corner of the image (outside the image) + # #x: image.tx is not None and image.tx or float('inf') + # #y: image.ty is not None and image.ty or float('inf') + # # (4) top right corner of the image (inside root, outside the image if possible) + # tr_x: root.width - self.width + # tr_y: root.height - self.height + # x: min(self.tr_x, image.tr_x is not None and image.tr_x or float('inf')) + # y: min(self.tr_y, image.tr_y is not None and image.tr_y or float('inf')) + # + # opacity: root.is_group and 1.0 or 0.0 + # show: 'image', : # This be a list item spacer: 20 diff --git a/tagit/widgets/browser.py b/tagit/widgets/browser.py index dace58b..1dfc528 100644 --- a/tagit/widgets/browser.py +++ b/tagit/widgets/browser.py @@ -5,9 +5,11 @@ A copy of the license is provided with the project. Author: Matthias Baumgartner, 2022 """ # standard imports +from collections import defaultdict from functools import reduce, partial import logging import math +import operator import os import typing @@ -24,9 +26,8 @@ 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 +from tagit.utils import Frame, Resolution, Struct, clamp, ns, ttime +from tagit.utils.bsfs import ast # inner-module imports from .loader import Loader @@ -167,13 +168,10 @@ class Browser(GridLayout, StorageAwareMixin, ConfigAwareMixin): """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()) + # get groups and their shadow (group's members in items) + groups = defaultdict(set) + for obj, grp in reduce(operator.add, items).group(node=True, view=list): + groups[grp].add(obj) # don't fold groups if few members fold_threshold = self.root.session.cfg('ui', 'standalone', 'browser', 'fold_threshold') @@ -190,7 +188,10 @@ class Browser(GridLayout, StorageAwareMixin, ConfigAwareMixin): # create folds folds = { - Representative.Representative(self.root.session.storage, grp): objs + grp.represented_by(): Struct( + group=grp, + shadow=objs, + ) for grp, objs in groups.items() if not superset_exists(grp) } @@ -198,25 +199,25 @@ class Browser(GridLayout, StorageAwareMixin, ConfigAwareMixin): # 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]]) + idx = min([items.index(obj) for obj in folds[rep].shadow]) items.insert(idx, rep) # remove folded items - for obj in reduce(set.union, folds.values(), set()): + for obj in {obj for fold in folds.values() for obj in fold.shadow}: items.remove(obj) return items, folds def unfold(self, items): """Replace group representatives by their group members.""" + # fetch each item or their shadow if applicable unfolded = set() - for obj in items: - if obj in self.folds: - unfolded |= self.folds[obj] + for itm in items: + if itm in self.folds: + unfolded |= self.folds[itm].shadow else: - unfolded.add(obj) - - return unfolded + unfolded |= {itm} + return reduce(operator.add, unfolded) def neighboring_unselected(self): """Return the item closest to the cursor and not being selected. May return None.""" @@ -279,7 +280,8 @@ class Browser(GridLayout, StorageAwareMixin, ConfigAwareMixin): def on_cursor(self, sender, cursor): if cursor is not None: - self.root.status.dispatch('on_status', truncate_dir(cursor.path)) + #self.root.status.dispatch('on_status', os.path.basename(next(iter(cursor.guids)))) + self.root.status.dispatch('on_status', cursor.filename(default='')) def on_items(self, sender, items): self.change_view = True @@ -394,13 +396,15 @@ class Browser(GridLayout, StorageAwareMixin, ConfigAwareMixin): # load previews for items # FIXME: Only relevant items, not all of them - return # FIXME: mb/port - 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): + #thumbs = items.preview(default=open(resource_find('no_preview.png'), 'rb')) # FIXME: mb/port + #resolution = self._cell_resolution() # FIXME: mb/port + #for ent, thumb, child in zip(reversed(items), reversed(thumbs), childs): # FIXME: mb/port + for ent, child in zip(reversed(items), childs): # FIXME: default/no preview handling - thumb = best_resolution_match(thumb, resolution) - child.update(ent, thumb, f'{ent.guid}x{resolution}') + #thumb = best_resolution_match(thumb, resolution) # FIXME: mb/port + #child.update(ent, thumb, f'{ent.guid}x{resolution}') # FIXME: mb/port + thumb = open(resource_find('no_preview.png'), 'rb') + child.update(ent, thumb, f'{ent}x{1234}') # load previews for items #resolution = self._cell_resolution() @@ -513,7 +517,8 @@ class BrowserItem(RelativeLayout): 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.load_image(buffer, source, obj.orientation(default=1)) + self.preview.set_size(self.size) def clear(self): @@ -529,7 +534,7 @@ class BrowserDescription(BrowserItem): def update(self, obj, buffer, source): super(BrowserDescription, self).update(obj) - self.preview.load_image(buffer, source, obj.p.get('orientation', 1)) + self.preview.load_image(buffer, source, obj.orientation(default=1)) self.preview.set_size((self.height, self.height)) def clear(self): @@ -542,25 +547,48 @@ class BrowserDescription(BrowserItem): 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"), + # get group and its members + grp = self.browser.folds[self.obj].group + # FIXME: Here we could actually use a predicate reversal for Nodes.get + # members = grp.get(ast.fetch.Node(ast.fetch.Predicate(ns.bse.group, reverse=True))) + members = self.browser.root.session.storage.get(ns.bsfs.File, + ast.filter.Any(ns.bse.group, ast.filter.Is(grp))) + # get group member's tags + member_tags = members.tag.label(node=True) + tags_all = set.intersection(*member_tags.values()) + tags_any = {tag for tags in member_tags.values() for tag in tags} + # get remaining info from representative + preds = self.obj.get( + ns.bse.mime, + ns.bsm.t_created, + ) + self.text = '{name} [size=20sp]x{count}[/size], {mime}, {time}\n[color=8f8f8f][b]{tags_all}[/b], [size=14sp]{tags_any}[/size][/color]'.format( + name=os.path.basename(next(iter(grp.guids))), + count=len(members), + mime=preds.get(ns.bse.mime, ''), + time=ttime.from_timestamp_loc( + preds.get(ns.bsm.t_created, ttime.timestamp_min)).strftime('%Y-%m-%d %H:%M'), tags_all=', '.join(sorted(tags_all)), tags_any=', '.join(sorted(tags_any - tags_all)), - )) + ) + elif self.obj is not None: + preds = self.obj.get( + ns.bse.filename, + ns.bse.filesize, + ns.bse.mime, + ns.bsm.t_created, + (ns.bse.tag, ns.bst.label), + ) + self.text = '[size=20sp]{filename}[/size], [size=18sp][i]{mime}[/i][/size] -- {time} -- {filesize}\n[color=8f8f8f][size=14sp]{tags}[/size][/color]'.format( + filename=preds.get(ns.bse.filename, 'n/a'), + mime=preds.get(ns.bse.mime, ''), + time=ttime.from_timestamp_loc( + preds.get(ns.bsm.t_created, ttime.timestamp_min)).strftime('%Y-%m-%d %H:%M'), + filesize=preds.get(ns.bse.filesize, 0), + tags=', '.join(sorted(preds.get((ns.bse.tag, ns.bst.label), []))), + ) else: - self.text = '[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)), - )) + self.text = '' class AsyncBufferImage(AsyncImage): -- cgit v1.2.3 From 56865c524bddaee9ec86d57e62af9524be80d1b3 Mon Sep 17 00:00:00 2001 From: Matthias Baumgartner Date: Wed, 25 Jan 2023 10:55:49 +0100 Subject: first tiles --- tagit/widgets/browser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'tagit/widgets') diff --git a/tagit/widgets/browser.py b/tagit/widgets/browser.py index 1dfc528..f778181 100644 --- a/tagit/widgets/browser.py +++ b/tagit/widgets/browser.py @@ -217,7 +217,7 @@ class Browser(GridLayout, StorageAwareMixin, ConfigAwareMixin): unfolded |= self.folds[itm].shadow else: unfolded |= {itm} - return reduce(operator.add, unfolded) + return reduce(operator.add, unfolded) # FIXME: What if items is empty? def neighboring_unselected(self): """Return the item closest to the cursor and not being selected. May return None.""" -- cgit v1.2.3 From f6de8a2f568419fd4ea818f3791242f177a87fba Mon Sep 17 00:00:00 2001 From: Matthias Baumgartner Date: Wed, 25 Jan 2023 11:31:08 +0100 Subject: search actions early port --- tagit/widgets/filter.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) (limited to 'tagit/widgets') diff --git a/tagit/widgets/filter.py b/tagit/widgets/filter.py index 332ad34..8a7c1a2 100644 --- a/tagit/widgets/filter.py +++ b/tagit/widgets/filter.py @@ -168,9 +168,8 @@ class Filter(BoxLayout, ConfigAwareMixin): if self.changed: self.redraw() # issue search - # FIXME: mb/port/parsing - #if self.run_search: - # self.root.trigger('Search') + if self.run_search: + self.root.trigger('Search') def redraw(self): self.tokens.clear_widgets() -- cgit v1.2.3 From bb8f0bfa26da38698fb0c9c04650c5c9a0aa66f2 Mon Sep 17 00:00:00 2001 From: Matthias Baumgartner Date: Wed, 25 Jan 2023 17:08:32 +0100 Subject: logging and status --- tagit/widgets/status.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) (limited to 'tagit/widgets') diff --git a/tagit/widgets/status.py b/tagit/widgets/status.py index fea52b9..e83b8d8 100644 --- a/tagit/widgets/status.py +++ b/tagit/widgets/status.py @@ -18,10 +18,7 @@ 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 +from tagit import config, dialogues, logger # inner-module imports from .browser import BrowserAwareMixin @@ -94,17 +91,16 @@ class Status(BoxLayout, BrowserAwareMixin, ConfigAwareMixin): logging.getLogger().root.removeHandler(self.handler_history) # status log event - return # FIXME: mb/port - self.handler_status = logger_config( - CallbackHandler(self.status_from_log), - ColorsMarkup, + self.handler_status = logger.logger_config( + logger.CallbackHandler(self.status_from_log), + logger.ColorsMarkup, cfg('ui', 'standalone', 'logging', 'status').to_tree(defaults=True)) logging.getLogger().root.addHandler(self.handler_status) # history (console) - self.handler_history = logger_config( - CallbackHandler(self.update_history), - ColorsMarkup, + self.handler_history = logger.logger_config( + logger.CallbackHandler(self.update_history), + logger.ColorsMarkup, cfg('ui', 'standalone', 'logging', 'console').to_tree(defaults=True)) logging.getLogger().root.addHandler(self.handler_history) -- cgit v1.2.3 From 4d0ce7fb62eaad3a1f705ec3c77744e3ebc96a9e Mon Sep 17 00:00:00 2001 From: Matthias Baumgartner Date: Sun, 29 Jan 2023 11:31:16 +0100 Subject: session actions port --- tagit/widgets/session.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) (limited to 'tagit/widgets') diff --git a/tagit/widgets/session.py b/tagit/widgets/session.py index f45ab35..e97a688 100644 --- a/tagit/widgets/session.py +++ b/tagit/widgets/session.py @@ -5,7 +5,7 @@ A copy of the license is provided with the project. Author: Matthias Baumgartner, 2022 """ # standard imports -from threading import current_thread +import os import typing # kivy imports @@ -16,8 +16,7 @@ import kivy.properties as kp # tagit imports from tagit import parsing 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 +from tagit.utils import bsfs # exports __all__: typing.Sequence[str] = ( @@ -63,10 +62,20 @@ class Session(Widget): def load(self, cfg): """Load the session from configuration *cfg*.""" - self.cfg = cfg + #self.log = load_log(cfg) # FIXME: mb/port # initialize storages from config - self.log = load_log(cfg) - self.storage = load_broker(cfg) + # open BSFS storage + store = bsfs.Open(cfg('session', 'bsfs')) + # check storage schema + # FIXME: how to properly load the required schema? + with open(os.path.join(os.path.dirname(__file__), '..', 'apps', 'port-schema.nt'), 'rt') as ifile: + required_schema = bsfs.schema.from_string(ifile.read()) + # FIXME: Since the store isn't persistent, we migrate to the required one here. + #if not required_schema <= store.schema: + # raise Exception('') + store.migrate(required_schema) + # replace current with new storage + self.storage = store def update_settings_key(self, key, value): # change setting -- cgit v1.2.3 From bfb86bdd23c2fb7211636841545b4e003f07b643 Mon Sep 17 00:00:00 2001 From: Matthias Baumgartner Date: Sun, 29 Jan 2023 12:01:11 +0100 Subject: geo tile --- tagit/widgets/browser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'tagit/widgets') diff --git a/tagit/widgets/browser.py b/tagit/widgets/browser.py index f778181..0cc65f6 100644 --- a/tagit/widgets/browser.py +++ b/tagit/widgets/browser.py @@ -70,7 +70,7 @@ class ItemIndex(list): """ def __init__(self, items): super(ItemIndex, self).__init__(items) - self._item_set = set(items) + self._item_set = set(items) # FIXME: mb/port: collect into a nodes instance? self._index = {itm: idx for idx, itm in enumerate(items)} def index(self, item): -- cgit v1.2.3 From c6856aa6fe2ad478dd5bc6285fb2544c150b2033 Mon Sep 17 00:00:00 2001 From: Matthias Baumgartner Date: Thu, 2 Feb 2023 10:04:03 +0100 Subject: filter port --- tagit/widgets/filter.py | 39 ++++++++++++++++++++++----------------- tagit/widgets/session.py | 1 + 2 files changed, 23 insertions(+), 17 deletions(-) (limited to 'tagit/widgets') diff --git a/tagit/widgets/filter.py b/tagit/widgets/filter.py index 8a7c1a2..15aefd6 100644 --- a/tagit/widgets/filter.py +++ b/tagit/widgets/filter.py @@ -22,8 +22,8 @@ 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 bsfs, errors +from tagit.utils import bsfs, errors, ns +from tagit.utils.bsfs import ast, matcher # inner-module imports from .session import ConfigAwareMixin @@ -120,20 +120,25 @@ class Filter(BoxLayout, ConfigAwareMixin): return query, sort def abbreviate(self, token): - return 'T' - # FIXME: mb/port/parsing - 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()) + matches = matcher.Filter() + if matches(token, ast.filter.Any(ns.bse.tag, ast.filter.Any(ns.bst.label, matcher.Any()))): + # tag token + return self.root.session.filter_to_string(token) + if matches(token, matcher.Partial(ast.filter.Is)) or \ + matches(token, ast.filter.Or(matcher.Rest(matcher.Partial(ast.filter.Is)))): + # exclusive token + return 'E' + if matches(token, ast.filter.Not(matcher.Partial(ast.filter.Is))) or \ + matches(token, ast.filter.Not(ast.filter.Or(matcher.Rest(matcher.Partial(ast.filter.Is))))): + # reduce token + return 'R' + if matches(token, ast.filter.Any(ns.bse.group, matcher.Any())): + # group token + return 'G' + if matches(token, ast.filter.Any(matcher.Partial(ast.filter.Predicate), matcher.Any())): + # generic token + return token.predicate.predicate.get('fragment', '?').title() + return '?' def show_address_once(self): """Single-shot address mode without changing the search mode.""" @@ -272,7 +277,7 @@ class Addressbar(TextInput): def __init__(self, tokens, **kwargs): super(Addressbar, self).__init__(**kwargs) - self.text = ast_to_string(ast.AND(tokens)) + self.text = self.root.session.filter_to_string(bsfs.ast.filter.And(tokens)) self._last_text = self.text def on_text_validate(self): diff --git a/tagit/widgets/session.py b/tagit/widgets/session.py index e97a688..c233a15 100644 --- a/tagit/widgets/session.py +++ b/tagit/widgets/session.py @@ -40,6 +40,7 @@ class Session(Widget): self.log = log # derived members self.filter_from_string = parsing.filter.FromString(self.storage.schema) + self.filter_to_string = parsing.filter.ToString(self.storage.schema) #self.sort_from_string = parsing.Sort(self.storage.schema) # FIXME: mb/port/parsing def __enter__(self): -- cgit v1.2.3 From e4b98fb261c83588ca1151a1c3f8891965051b2f Mon Sep 17 00:00:00 2001 From: Matthias Baumgartner Date: Fri, 3 Feb 2023 17:24:40 +0100 Subject: previews in browser --- tagit/widgets/browser.py | 59 ++++++++++++++++++++++++++++++------------------ 1 file changed, 37 insertions(+), 22 deletions(-) (limited to 'tagit/widgets') diff --git a/tagit/widgets/browser.py b/tagit/widgets/browser.py index 0cc65f6..4a254ee 100644 --- a/tagit/widgets/browser.py +++ b/tagit/widgets/browser.py @@ -26,7 +26,7 @@ import kivy.properties as kp # tagit imports from tagit import config from tagit.external.setproperty import SetProperty -from tagit.utils import Frame, Resolution, Struct, clamp, ns, ttime +from tagit.utils import Frame, Resolution, Struct, clamp, ns, ttime, rmatcher from tagit.utils.bsfs import ast # inner-module imports @@ -394,26 +394,39 @@ class Browser(GridLayout, StorageAwareMixin, ConfigAwareMixin): 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')) # FIXME: mb/port - #resolution = self._cell_resolution() # FIXME: mb/port - #for ent, thumb, child in zip(reversed(items), reversed(thumbs), childs): # FIXME: mb/port + if len(items) == 0: # FIXME: mb/port + return + + # fetch previews + node_preview = reduce(operator.add, items).get(ns.bse.preview, node=True) + previews = {p for previews in node_preview.values() for p in previews} + previews = reduce(operator.add, previews) + # fetch preview resolutions + res_preview = previews.get(ns.bsp.width, ns.bsp.height, node=True) + # get target resolution + resolution = self._cell_resolution() + # get default preview + default = resource_find('no_preview.png') + # select a preview for each item for ent, child in zip(reversed(items), childs): - # FIXME: default/no preview handling - #thumb = best_resolution_match(thumb, resolution) # FIXME: mb/port - #child.update(ent, thumb, f'{ent.guid}x{resolution}') # FIXME: mb/port - thumb = open(resource_find('no_preview.png'), 'rb') - child.update(ent, thumb, f'{ent}x{1234}') - - # 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}') + try: + # get previews and their resolution for this ent + options = [] + for preview in node_preview[ent]: + # unpack resolution + res = res_preview[preview] + width = res.get(ns.bsp.width, 0) + height = res.get(ns.bsp.height, 0) + options.append((preview, Resolution(width, height))) + # select the best fitting preview + chosen = rmatcher.by_area_min(resolution, options) + # open the preview file, default if no asset is available + thumb = open(chosen.get(ns.bsp.asset, default=default), 'rb') # FIXME: mb/port: asset storage + except IndexError: + # no viable resolution found + thumb = open(default, 'rb') + # update the image in the child widget + child.update(ent, thumb, f'{ent}x{resolution}') #def _preload_all(self): # # prefer loading from start to end @@ -517,7 +530,8 @@ class BrowserItem(RelativeLayout): class BrowserImage(BrowserItem): def update(self, obj, buffer, source): super(BrowserImage, self).update(obj) - self.preview.load_image(buffer, source, obj.orientation(default=1)) + self.preview.load_image(buffer, source, 1) + #self.preview.load_image(buffer, source, obj.orientation(default=1)) # FIXME: mb/port self.preview.set_size(self.size) @@ -534,7 +548,8 @@ class BrowserDescription(BrowserItem): def update(self, obj, buffer, source): super(BrowserDescription, self).update(obj) - self.preview.load_image(buffer, source, obj.orientation(default=1)) + self.preview.load_image(buffer, source, 1) + #self.preview.load_image(buffer, source, obj.orientation(default=1)) # FIXME: mb/port self.preview.set_size((self.height, self.height)) def clear(self): -- cgit v1.2.3 From f39d577421bc2e4b041b5d22e788f4615ef78d77 Mon Sep 17 00:00:00 2001 From: Matthias Baumgartner Date: Wed, 8 Feb 2023 21:16:55 +0100 Subject: adapt to upstream changes --- tagit/widgets/browser.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) (limited to 'tagit/widgets') diff --git a/tagit/widgets/browser.py b/tagit/widgets/browser.py index 4a254ee..1e42c9c 100644 --- a/tagit/widgets/browser.py +++ b/tagit/widgets/browser.py @@ -7,6 +7,7 @@ Author: Matthias Baumgartner, 2022 # standard imports from collections import defaultdict from functools import reduce, partial +import io import logging import math import operator @@ -421,8 +422,13 @@ class Browser(GridLayout, StorageAwareMixin, ConfigAwareMixin): # select the best fitting preview chosen = rmatcher.by_area_min(resolution, options) # open the preview file, default if no asset is available - thumb = open(chosen.get(ns.bsp.asset, default=default), 'rb') # FIXME: mb/port: asset storage - except IndexError: + thumb_data = chosen.asset(default=None) # FIXME: get all assets in one call + if thumb_data is None: + raise KeyError() + thumb = io.BytesIO(thumb_data) + + except (KeyError, IndexError): + # KeyError: # no viable resolution found thumb = open(default, 'rb') # update the image in the child widget -- cgit v1.2.3 From 580caf6f5c9b795f9c38b9c970efce12d006ce1d Mon Sep 17 00:00:00 2001 From: Matthias Baumgartner Date: Fri, 17 Feb 2023 08:25:44 +0100 Subject: New UI design * Moved style definitions to its own file (themes/default) * Updated the desktop.kv to the new UI design * Removed planes * Adjusted port config --- tagit/widgets/browser.kv | 23 +++++-------------- tagit/widgets/browser.py | 7 +----- tagit/widgets/filter.kv | 59 +++++++++++++++++++----------------------------- tagit/widgets/status.kv | 16 ++++++++----- 4 files changed, 40 insertions(+), 65 deletions(-) (limited to 'tagit/widgets') diff --git a/tagit/widgets/browser.kv b/tagit/widgets/browser.kv index 8b4c8c3..63495be 100644 --- a/tagit/widgets/browser.kv +++ b/tagit/widgets/browser.kv @@ -11,19 +11,6 @@ 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 - : # This be an image preview: image @@ -58,6 +45,11 @@ # opacity: root.is_group and 1.0 or 0.0 # show: 'image', +: + halign: 'left' + valign: 'center' + text_size: self.size + : # This be a list item spacer: 20 preview: image @@ -68,12 +60,9 @@ # actual size is set in code pos: 0, 0 - Label: + BrowserDescriptionLabel: 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 diff --git a/tagit/widgets/browser.py b/tagit/widgets/browser.py index 1e42c9c..bbc3748 100644 --- a/tagit/widgets/browser.py +++ b/tagit/widgets/browser.py @@ -281,7 +281,6 @@ class Browser(GridLayout, StorageAwareMixin, ConfigAwareMixin): def on_cursor(self, sender, cursor): if cursor is not None: - #self.root.status.dispatch('on_status', os.path.basename(next(iter(cursor.guids)))) self.root.status.dispatch('on_status', cursor.filename(default='')) def on_items(self, sender, items): @@ -347,10 +346,7 @@ class Browser(GridLayout, StorageAwareMixin, ConfigAwareMixin): self.clear_widgets() for itm in range(self.page_size): - wx = factory( - browser=self, - scolor=self.root.session.cfg('ui', 'standalone', 'browser', 'select_color'), - ) + wx = factory(browser=self) self.bind(selection=wx.on_selection) self.bind(cursor=wx.on_cursor) self.add_widget(wx) @@ -484,7 +480,6 @@ class BrowserItem(RelativeLayout): 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 diff --git a/tagit/widgets/filter.kv b/tagit/widgets/filter.kv index b638570..df9a678 100644 --- a/tagit/widgets/filter.kv +++ b/tagit/widgets/filter.kv @@ -1,4 +1,5 @@ #:import SearchmodeSwitch tagit.actions.filter +#:import AddToken tagit.actions.filter #-- #:import SortKey tagit.actions.search : @@ -14,6 +15,10 @@ # Tokens will be inserted here + AddToken: + show: 'image', + root: root.root + SearchmodeSwitch: show: 'image', root: root.root @@ -30,55 +35,37 @@ root: root.root name: 'filter' orientation: 'lr-tb' - # space for 2 buttons - width: 3*30 + 2*5 - size_hint: None, 1.0 + # space for two buttons + width: 2*30 + 5 spacing: 5 + size_hint: None, 1 button_height: 30 button_show: 'image', +: + active: False + +: + active: False + : 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 + Avatar: + id: avatar + text: 'T' + size_hint: None, None + width: self.parent.height + height: self.parent.height + active: root.active - canvas.after: - Color: - rgba: 1,1,1,1 - Line: - rectangle: self.x+1, self.y+1, self.width-1, self.height-1 - - Label: + ShingleText: 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() + active: root.active : 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/status.kv b/tagit/widgets/status.kv index 2d49b15..0a680ab 100644 --- a/tagit/widgets/status.kv +++ b/tagit/widgets/status.kv @@ -1,5 +1,13 @@ #-- #:import ButtonDock tagit.widgets.dock.ButtonDock # FIXME: mb/port +: + markup: True + +: + markup: True + valign: 'middle' + halign: 'center' + : orientation: 'horizontal' status: '' @@ -18,11 +26,10 @@ button_height: 30 button_show: 'image', - Label: + NavigationLabel: id: navigation_label size_hint: None, 1 width: 180 - markup: True text: root.navigation ButtonDock: @@ -36,13 +43,10 @@ button_height: 30 button_show: 'image', - Label: + StatusLabel: # gets remaining size id: status_label text_size: self.size - markup: True - valign: 'middle' - halign: 'left' text: root.status ButtonDock: -- cgit v1.2.3 From 906076a24fd3baca68e0381aca1953a05f5b45b7 Mon Sep 17 00:00:00 2001 From: Matthias Baumgartner Date: Fri, 17 Feb 2023 08:29:26 +0100 Subject: Fixes: * Preview loading in browser * Search via bsfs.Graph.sorted to preserve order * Fixes in parsing.filter.to_string --- tagit/widgets/browser.py | 78 ++++++++++++++++++++++++++++-------------------- 1 file changed, 45 insertions(+), 33 deletions(-) (limited to 'tagit/widgets') diff --git a/tagit/widgets/browser.py b/tagit/widgets/browser.py index bbc3748..28f7440 100644 --- a/tagit/widgets/browser.py +++ b/tagit/widgets/browser.py @@ -394,18 +394,30 @@ class Browser(GridLayout, StorageAwareMixin, ConfigAwareMixin): if len(items) == 0: # FIXME: mb/port return + resolution = self._cell_resolution() + previews = self._fetch_previews(items, resolution) + default = resource_find('no_preview.png') + for ent, child in zip(reversed(items), childs): + if ent in previews: + buf = previews[ent] + else: + buf = open(default, 'rb') + child.update(ent, buf, f'{ent}x{resolution}') + + def _fetch_previews(self, items, resolution): + """Fetch previews matching *resolution* for *items*. + Return a dict with items as key and a BytesIO as value. + Items without valid asset are omitted from the dict. + """ # fetch previews node_preview = reduce(operator.add, items).get(ns.bse.preview, node=True) previews = {p for previews in node_preview.values() for p in previews} - previews = reduce(operator.add, previews) + previews = reduce(operator.add, previews) # FIXME: empty previews # fetch preview resolutions res_preview = previews.get(ns.bsp.width, ns.bsp.height, node=True) - # get target resolution - resolution = self._cell_resolution() - # get default preview - default = resource_find('no_preview.png') # select a preview for each item - for ent, child in zip(reversed(items), childs): + chosen = {} + for ent in items: try: # get previews and their resolution for this ent options = [] @@ -416,19 +428,21 @@ class Browser(GridLayout, StorageAwareMixin, ConfigAwareMixin): height = res.get(ns.bsp.height, 0) options.append((preview, Resolution(width, height))) # select the best fitting preview - chosen = rmatcher.by_area_min(resolution, options) - # open the preview file, default if no asset is available - thumb_data = chosen.asset(default=None) # FIXME: get all assets in one call - if thumb_data is None: - raise KeyError() - thumb = io.BytesIO(thumb_data) - + chosen[ent] = rmatcher.by_area_min(resolution, options) except (KeyError, IndexError): - # KeyError: - # no viable resolution found - thumb = open(default, 'rb') - # update the image in the child widget - child.update(ent, thumb, f'{ent}x{resolution}') + # skip objects w/o preview (KeyError in node_preview) + # skip objects w/o valid preview (IndexError in rmatcher) + pass + + # fetch assets + assets = reduce(operator.add, chosen.values()).asset(node=True) # FIXME: empty chosen + # build ent -> asset mapping and convert raw data to io buffer + return { + ent: io.BytesIO(assets[thumb]) + for ent, thumb + in chosen.items() + if thumb in assets + } #def _preload_all(self): # # prefer loading from start to end @@ -436,26 +450,24 @@ class Browser(GridLayout, StorageAwareMixin, ConfigAwareMixin): 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 + resolution = resolution if resolution is not None else self._cell_resolution() + try: + foo = self._fetch_previews(items, resolution) # FIXME: _fetch_previews fails on empty previews/chosen + except TypeError: + return + for obj, buffer in foo.items(): + guid = ','.join(obj.guids) + source = f'{guid}x{resolution}' + Loader.image(source, + nocache=False, mipmap=False, + anim_delay=0, + load_callback=partial(_buf_loader, buffer) # mb: pass load_callback + ) class BrowserAwareMixin(object): -- cgit v1.2.3 From 1b06707e6cecfd87533c61b77455b6930b341cd8 Mon Sep 17 00:00:00 2001 From: Matthias Baumgartner Date: Fri, 17 Feb 2023 17:52:06 +0100 Subject: empty browser fix --- tagit/widgets/browser.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) (limited to 'tagit/widgets') diff --git a/tagit/widgets/browser.py b/tagit/widgets/browser.py index 28f7440..8445706 100644 --- a/tagit/widgets/browser.py +++ b/tagit/widgets/browser.py @@ -171,7 +171,8 @@ class Browser(GridLayout, StorageAwareMixin, ConfigAwareMixin): """ # get groups and their shadow (group's members in items) groups = defaultdict(set) - for obj, grp in reduce(operator.add, items).group(node=True, view=list): + all_items = reduce(operator.add, items, self.root.session.storage.empty(ns.bsfs.File)) + for obj, grp in all_items.group(node=True, view=list): groups[grp].add(obj) # don't fold groups if few members @@ -218,7 +219,7 @@ class Browser(GridLayout, StorageAwareMixin, ConfigAwareMixin): unfolded |= self.folds[itm].shadow else: unfolded |= {itm} - return reduce(operator.add, unfolded) # FIXME: What if items is empty? + return reduce(operator.add, unfolded, self.root.session.storage.empty(ns.bsfs.File)) def neighboring_unselected(self): """Return the item closest to the cursor and not being selected. May return None.""" -- cgit v1.2.3 From b6f43e68f864f1c15bd56e9775da8edf5c1ac27b Mon Sep 17 00:00:00 2001 From: Matthias Baumgartner Date: Fri, 17 Feb 2023 17:53:03 +0100 Subject: filter design update --- tagit/widgets/filter.kv | 44 +++++++++++++++++++++++++++++++------------- tagit/widgets/filter.py | 20 ++++++++++++++++++-- 2 files changed, 49 insertions(+), 15 deletions(-) (limited to 'tagit/widgets') diff --git a/tagit/widgets/filter.kv b/tagit/widgets/filter.kv index df9a678..5407610 100644 --- a/tagit/widgets/filter.kv +++ b/tagit/widgets/filter.kv @@ -8,24 +8,36 @@ spacing: 5 tokens: tokens - BoxLayout: - orientation: 'horizontal' - spacing: 10 - id: tokens + Widget: + size_hint_x: None + width: 5 - # Tokens will be inserted here + ScrollView: + do_scroll_x: True + do_scroll_y: False + size_hint: 1, 1 + + BoxLayout: + orientation: 'horizontal' + spacing: 10 + id: tokens + size_hint: None, None + height: 35 + width: self.minimum_width + # Tokens will be inserted here AddToken: show: 'image', root: root.root - SearchmodeSwitch: - show: 'image', - root: root.root + # FIXME: Temporarily disabled + #SearchmodeSwitch: + # show: 'image', + # root: root.root - SortKey: - show: 'image', - root: root.root + #SortKey: + # show: 'image', + # root: root.root SortOrder: show: 'image', @@ -38,7 +50,8 @@ # space for two buttons width: 2*30 + 5 spacing: 5 - size_hint: None, 1 + size_hint: None, None + height: 35 button_height: 30 button_show: 'image', @@ -51,11 +64,14 @@ : orientation: 'horizontal' label: tlabel + size_hint: None, None + width: self.minimum_width + height: 30 Avatar: id: avatar - text: 'T' size_hint: None, None + text: root.avatar width: self.parent.height height: self.parent.height active: root.active @@ -64,6 +80,8 @@ id: tlabel text: root.text active: root.active + width: (self.texture_size[0] + dp(20)) if self.text != '' else 0 + size_hint_x: None : multiline: False diff --git a/tagit/widgets/filter.py b/tagit/widgets/filter.py index 15aefd6..76394e3 100644 --- a/tagit/widgets/filter.py +++ b/tagit/widgets/filter.py @@ -123,7 +123,7 @@ class Filter(BoxLayout, ConfigAwareMixin): matches = matcher.Filter() if matches(token, ast.filter.Any(ns.bse.tag, ast.filter.Any(ns.bst.label, matcher.Any()))): # tag token - return self.root.session.filter_to_string(token) + return 'T' if matches(token, matcher.Partial(ast.filter.Is)) or \ matches(token, ast.filter.Or(matcher.Rest(matcher.Partial(ast.filter.Is)))): # exclusive token @@ -140,6 +140,20 @@ class Filter(BoxLayout, ConfigAwareMixin): return token.predicate.predicate.get('fragment', '?').title() return '?' + def tok_label(self, token): + matches = matcher.Filter() + if matches(token, ast.filter.Any(ns.bse.tag, ast.filter.Any(ns.bst.label, matcher.Any()))): + # tag token + return self.root.session.filter_to_string(token) + if matches(token, matcher.Partial(ast.filter.Is)) or \ + matches(token, ast.filter.Not(matcher.Partial(ast.filter.Is))): + return '1' + if matches(token, ast.filter.Or(matcher.Rest(matcher.Partial(ast.filter.Is)))): + return str(len(token)) + if matches(token, ast.filter.Not(ast.filter.Or(matcher.Rest(matcher.Partial(ast.filter.Is))))): + return str(len(token.expr)) + return '' + def show_address_once(self): """Single-shot address mode without changing the search mode.""" self.tokens.clear_widgets() @@ -189,7 +203,8 @@ class Filter(BoxLayout, ConfigAwareMixin): Shingle( tok, active=(tok in self.t_head), - text=self.abbreviate(tok), + avatar=self.abbreviate(tok), + text=self.tok_label(tok), root=self.root )) @@ -235,6 +250,7 @@ class Shingle(BoxLayout): # content active = kp.BooleanProperty(False) text = kp.StringProperty('') + avatar = kp.StringProperty('') # touch behaviour _single_tap_action = None -- cgit v1.2.3 From 141cfeade2750e0255ca010079421efce4abeca2 Mon Sep 17 00:00:00 2001 From: Matthias Baumgartner Date: Sat, 4 Mar 2023 14:16:00 +0100 Subject: namespace updates --- tagit/widgets/browser.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'tagit/widgets') diff --git a/tagit/widgets/browser.py b/tagit/widgets/browser.py index 8445706..17d99ed 100644 --- a/tagit/widgets/browser.py +++ b/tagit/widgets/browser.py @@ -171,7 +171,7 @@ class Browser(GridLayout, StorageAwareMixin, ConfigAwareMixin): """ # get groups and their shadow (group's members in items) groups = defaultdict(set) - all_items = reduce(operator.add, items, self.root.session.storage.empty(ns.bsfs.File)) + all_items = reduce(operator.add, items, self.root.session.storage.empty(ns.bsn.Entity)) for obj, grp in all_items.group(node=True, view=list): groups[grp].add(obj) @@ -219,7 +219,7 @@ class Browser(GridLayout, StorageAwareMixin, ConfigAwareMixin): unfolded |= self.folds[itm].shadow else: unfolded |= {itm} - return reduce(operator.add, unfolded, self.root.session.storage.empty(ns.bsfs.File)) + return reduce(operator.add, unfolded, self.root.session.storage.empty(ns.bsn.Entity)) def neighboring_unselected(self): """Return the item closest to the cursor and not being selected. May return None.""" @@ -580,7 +580,7 @@ class BrowserDescription(BrowserItem): grp = self.browser.folds[self.obj].group # FIXME: Here we could actually use a predicate reversal for Nodes.get # members = grp.get(ast.fetch.Node(ast.fetch.Predicate(ns.bse.group, reverse=True))) - members = self.browser.root.session.storage.get(ns.bsfs.File, + members = self.browser.root.session.storage.get(ns.bsn.Entity, ast.filter.Any(ns.bse.group, ast.filter.Is(grp))) # get group member's tags member_tags = members.tag.label(node=True) -- cgit v1.2.3 From 662842453a00d446c420b04ae5fa5e8735fa09c2 Mon Sep 17 00:00:00 2001 From: Matthias Baumgartner Date: Sat, 4 Mar 2023 14:19:49 +0100 Subject: style fixes --- tagit/widgets/filter.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) (limited to 'tagit/widgets') diff --git a/tagit/widgets/filter.py b/tagit/widgets/filter.py index 76394e3..1382c43 100644 --- a/tagit/widgets/filter.py +++ b/tagit/widgets/filter.py @@ -120,6 +120,7 @@ class Filter(BoxLayout, ConfigAwareMixin): return query, sort def abbreviate(self, token): + # FIXME: Return image matches = matcher.Filter() if matches(token, ast.filter.Any(ns.bse.tag, ast.filter.Any(ns.bst.label, matcher.Any()))): # tag token @@ -127,17 +128,18 @@ class Filter(BoxLayout, ConfigAwareMixin): if matches(token, matcher.Partial(ast.filter.Is)) or \ matches(token, ast.filter.Or(matcher.Rest(matcher.Partial(ast.filter.Is)))): # exclusive token - return 'E' + return '=' if matches(token, ast.filter.Not(matcher.Partial(ast.filter.Is))) or \ matches(token, ast.filter.Not(ast.filter.Or(matcher.Rest(matcher.Partial(ast.filter.Is))))): # reduce token - return 'R' + return '—' if matches(token, ast.filter.Any(ns.bse.group, matcher.Any())): # group token return 'G' if matches(token, ast.filter.Any(matcher.Partial(ast.filter.Predicate), matcher.Any())): # generic token - return token.predicate.predicate.get('fragment', '?').title() + #return token.predicate.predicate.get('fragment', '?').title()[0] + return 'P' return '?' def tok_label(self, token): @@ -152,6 +154,10 @@ class Filter(BoxLayout, ConfigAwareMixin): return str(len(token)) if matches(token, ast.filter.Not(ast.filter.Or(matcher.Rest(matcher.Partial(ast.filter.Is))))): return str(len(token.expr)) + if matches(token, ast.filter.Any(matcher.Partial(ast.filter.Predicate), matcher.Any())): + # generic token + #return self.root.session.filter_to_string(token) + return token.predicate.predicate.get('fragment', '') return '' def show_address_once(self): @@ -265,7 +271,7 @@ class Shingle(BoxLayout): def on_touch_down(self, touch): """Edit shingle when touched.""" - if self.label.collide_point(*touch.pos): + if self.collide_point(*touch.pos): if touch.is_double_tap: # edit filter # ignore touch, such that the dialogue # doesn't loose the focus immediately after open -- cgit v1.2.3 From 01a4c2fc4bcbcce26c29dc9771dedeef5256156b Mon Sep 17 00:00:00 2001 From: Matthias Baumgartner Date: Sat, 4 Mar 2023 16:00:46 +0100 Subject: schema requirements checking --- tagit/widgets/session.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) (limited to 'tagit/widgets') diff --git a/tagit/widgets/session.py b/tagit/widgets/session.py index c233a15..30dfe51 100644 --- a/tagit/widgets/session.py +++ b/tagit/widgets/session.py @@ -68,13 +68,13 @@ class Session(Widget): # open BSFS storage store = bsfs.Open(cfg('session', 'bsfs')) # check storage schema - # FIXME: how to properly load the required schema? - with open(os.path.join(os.path.dirname(__file__), '..', 'apps', 'port-schema.nt'), 'rt') as ifile: + with open(resource_find('required_schema.nt'), 'rt') as ifile: required_schema = bsfs.schema.from_string(ifile.read()) - # FIXME: Since the store isn't persistent, we migrate to the required one here. - #if not required_schema <= store.schema: - # raise Exception('') - store.migrate(required_schema) + if not required_schema.consistent_with(store.schema): + raise Exception("The storage's schema is incompatible with tagit's requirements") + if not required_schema <= store.schema: + store.migrate(required_schema | store.schema) + # replace current with new storage self.storage = store -- cgit v1.2.3