From 547124605a9f86469a547fcaf38dc18ae57b707f Mon Sep 17 00:00:00 2001 From: Matthias Baumgartner Date: Thu, 5 Jan 2023 23:55:02 +0100 Subject: essential structures --- tagit/apps/__init__.py | 10 +++++++++ tagit/apps/desktop.py | 55 +++++++++++++++++++++++++++++++++++++++++++++++++ tagit/utils/__init__.py | 18 ++++++++++++++++ tagit/utils/bsfs.py | 15 ++++++++++++++ 4 files changed, 98 insertions(+) create mode 100644 tagit/apps/__init__.py create mode 100644 tagit/apps/desktop.py create mode 100644 tagit/utils/__init__.py create mode 100644 tagit/utils/bsfs.py (limited to 'tagit') diff --git a/tagit/apps/__init__.py b/tagit/apps/__init__.py new file mode 100644 index 0000000..4c64128 --- /dev/null +++ b/tagit/apps/__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 main as desktop + +## EOF ## diff --git a/tagit/apps/desktop.py b/tagit/apps/desktop.py new file mode 100644 index 0000000..67733f0 --- /dev/null +++ b/tagit/apps/desktop.py @@ -0,0 +1,55 @@ +""" + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 +""" +# standard imports +import typing + +# kivy imports +from kivy.app import App +from kivy.uix.settings import SettingsWithSidebar + +# tagit imports +from tagit.widgets import desktop + +# exports +__all__: typing.Sequence[str] = ( + 'main', + ) + + +## code ## + +class TagitApp(App): + """The tagit main application.""" + + def build(self): + # set settings panel style + self.settings_cls = SettingsWithSidebar + + # set title + self.title = 'tagit v2.0' + + # create widget + return desktop.MainWindow() + + def on_start(self): + # trigger startup operations + self.root.on_startup() + + +def main(): + """Start the tagit GUI. Opens a window to browse images.""" + # Run the GUI + app = TagitApp() + app.run() + + +## main ## + +if __name__ == '__main__': + main() + +## EOF ## diff --git a/tagit/utils/__init__.py b/tagit/utils/__init__.py new file mode 100644 index 0000000..d5a8efe --- /dev/null +++ b/tagit/utils/__init__.py @@ -0,0 +1,18 @@ +""" + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 +""" +# imports +import typing + +# inner-module imports +from . import bsfs + +# exports +__all__: typing.Sequence[str] = ( + 'bsfs', + ) + +## EOF ## diff --git a/tagit/utils/bsfs.py b/tagit/utils/bsfs.py new file mode 100644 index 0000000..0ab90a9 --- /dev/null +++ b/tagit/utils/bsfs.py @@ -0,0 +1,15 @@ +"""BSFS bridge, provides BSFS bindings for tagit. + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 +""" +# imports +import typing + +# bsfs imports + +# exports +__all__: typing.Sequence[str] = [] + +## EOF ## -- cgit v1.2.3 From 11e0cc65dfa7158b987c25557775732c5eafba87 Mon Sep 17 00:00:00 2001 From: Matthias Baumgartner Date: Fri, 6 Jan 2023 11:58:55 +0100 Subject: bugfix --- tagit/apps/desktop.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'tagit') diff --git a/tagit/apps/desktop.py b/tagit/apps/desktop.py index 67733f0..086a503 100644 --- a/tagit/apps/desktop.py +++ b/tagit/apps/desktop.py @@ -40,7 +40,7 @@ class TagitApp(App): self.root.on_startup() -def main(): +def main(argv): """Start the tagit GUI. Opens a window to browse images.""" # Run the GUI app = TagitApp() -- cgit v1.2.3 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/__init__.py | 2 +- tagit/widgets/__init__.py | 10 ++ tagit/widgets/desktop.kv | 130 ++++++++++++++++++++++++ tagit/widgets/desktop.py | 254 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 395 insertions(+), 1 deletion(-) create mode 100644 tagit/widgets/__init__.py create mode 100644 tagit/widgets/desktop.kv create mode 100644 tagit/widgets/desktop.py (limited to 'tagit') diff --git a/tagit/__init__.py b/tagit/__init__.py index 7197091..dda8ea7 100644 --- a/tagit/__init__.py +++ b/tagit/__init__.py @@ -4,7 +4,7 @@ Part of the tagit module. A copy of the license is provided with the project. Author: Matthias Baumgartner, 2022 """ -# imports +# standard imports import collections import typing 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 079b4da93ea336b5bcc801cfd64c310aa7f8ddee Mon Sep 17 00:00:00 2001 From: Matthias Baumgartner Date: Fri, 6 Jan 2023 12:20:22 +0100 Subject: config early port (test still fails) --- tagit/config/__init__.py | 25 +++ tagit/config/loader.py | 83 +++++++ tagit/config/schema.py | 283 ++++++++++++++++++++++++ tagit/config/settings.json | 70 ++++++ tagit/config/settings.py | 473 ++++++++++++++++++++++++++++++++++++++++ tagit/config/types.py | 273 +++++++++++++++++++++++ tagit/config/user-defaults.json | 142 ++++++++++++ tagit/config/utils.py | 104 +++++++++ tagit/utils/__init__.py | 1 + tagit/utils/errors.py | 52 +++++ tagit/utils/shared.py | 63 ++++++ 11 files changed, 1569 insertions(+) create mode 100644 tagit/config/__init__.py create mode 100644 tagit/config/loader.py create mode 100644 tagit/config/schema.py create mode 100644 tagit/config/settings.json create mode 100644 tagit/config/settings.py create mode 100644 tagit/config/types.py create mode 100644 tagit/config/user-defaults.json create mode 100644 tagit/config/utils.py create mode 100644 tagit/utils/errors.py create mode 100644 tagit/utils/shared.py (limited to 'tagit') diff --git a/tagit/config/__init__.py b/tagit/config/__init__.py new file mode 100644 index 0000000..c9edb15 --- /dev/null +++ b/tagit/config/__init__.py @@ -0,0 +1,25 @@ +"""Configuration system. + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 +""" +# standard imports +import typing + +# inner-module imports +from . import loader +from . import utils +from .loader import TAGITRC, DEFAULT_USER_CONFIG +from .schema import schema, declare, declare_title +from .settings import Settings, ConfigError +from .types import * + +# exports +__all__: typing.Sequence[str] = ( + 'Settings', + 'TAGITRC', + 'schema', + ) + +## EOF ## diff --git a/tagit/config/loader.py b/tagit/config/loader.py new file mode 100644 index 0000000..489b063 --- /dev/null +++ b/tagit/config/loader.py @@ -0,0 +1,83 @@ +"""High-level config loading. + +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 shutil +import typing + +# inner-module imports +from .settings import Settings + +# constants + +TAGITRC = '.tagitrc' + +DEFAULT_USER_CONFIG = os.path.join(os.path.dirname(__file__), 'user-defaults.json') + +SETTINGS_PATH = [ + # user home + os.path.expanduser(os.path.join('~', TAGITRC)), + # installation directory + '/usr/share/tagit/settings', + '/usr/share/tagit/keybindings', + # module defaults + os.path.join(os.path.dirname(__file__), 'settings.json'), + ] + +# exports +__all__: typing.Sequence[str] = ( + 'load_settings', + ) + + +## code ## + +logger = logging.getLogger(__name__) + +def load_settings(path=None, verbose=0): + """Load application settings. + The settings are loaded from the specified *path* and from all default + search paths (see *SETTINGS_PATH*). More specific locations overwrite + less specific ones. Every config key comes with a default value that + applies if it is not specified in the config files. + """ + verbose = max(0, verbose) + + # build searchpaths + searchpaths = [] + searchpaths += [path] if path is not None else [] + searchpaths += SETTINGS_PATH + + # create default user config on first start + first_start = False + user_config = os.path.expanduser(os.path.join('~', TAGITRC)) + if os.path.exists(DEFAULT_USER_CONFIG) and not os.path.exists(user_config): + first_start = True + shutil.copy(DEFAULT_USER_CONFIG, user_config) + + # scan searchpaths + cfg = Settings() + for path in searchpaths[::-1]: + if verbose > 0 or cfg('session', 'verbose') > 0: + print(f'Loading settings from {path}') + + if path is not None and os.path.exists(path): + try: + cfg.update(Settings.Open(path, clear_defaults=False)) + + except TypeError as e: # schema violation + logger.critical(f'Encountered a config error while loading {path}: {e}') + raise e + + # update verbosity from argument + cfg.set(('session', 'verbose'), max(cfg('session', 'verbose'), verbose)) + # set first start according to previous user config existence + cfg.set(('session', 'first_start'), first_start) + return cfg + +## EOF ## diff --git a/tagit/config/schema.py b/tagit/config/schema.py new file mode 100644 index 0000000..7f1c17a --- /dev/null +++ b/tagit/config/schema.py @@ -0,0 +1,283 @@ +"""Definition of a configuration schema. + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 +""" +# standard imports +from collections import abc +import logging +import typing + +# tagit imports +from tagit.utils import errors, fst + +# exports +__all__: typing.Sequence[str] = ( + 'ConfigSchema', + 'declare', + 'declare_title', + ) + + +## code ## + +logger = logging.getLogger(__name__) + +class IncompatibleTypes(Exception): + """Raised if a config key is declared multiple times with incompatible signatures.""" + pass + +class ConfigSchema(abc.Collection, abc.Callable, abc.Hashable): + """The config schema registers types, defaults, and documentation for + configuration keys. The specification of a config key can be accessed in + dict-style (schema[key]) or Settings-style (schema(key)). A global schema + is instantiated to be used by tagit modules to declare their config keys. + + In addition to config keys, the class supports titles for documentation + of configuration sections (essentially any part of a config key that has + no value assigned to it). + """ + def __init__(self): + self.config = dict() + self.titles = dict() + + ## interfaces + + def __hash__(self): + return hash((type(self), + tuple(sorted([(hash(k), hash(v)) for k, v in self.config.items()], key=fst)), + tuple(sorted([(hash(k), hash(v)) for k, v in self.titles.items()], key=fst)))) + + def __call__(self, *key): + """Return the definition of a *key*.""" + return self.config[key] + + def __getitem__(self, key): + """Return the definition of a *key*.""" + return self.config[key] + + def __contains__(self, key): + """Return True if the config key *key* was declared.""" + return key in self.config + + def __iter__(self): + """Iterate over all declared config keys.""" + return iter(self.config) + + def __len__(self): + """Return the number of declared config keys.""" + return len(self.config) + + def keys(self, titles=False): + """Return an iterator over all declared config keys. + If *titles* is True, also return the declared title keys. + """ + if titles: + return iter(set(self.config.keys()) | set(self.titles.keys())) + else: + return self.config.keys() + + ## titles extras + + def is_title(self, key): + """Return True if the *key* matches a section.""" + return key in self.titles and key not in self.config + + def get_title(self, key): + """Return the section title of *key*.""" + return self.titles[key] + + ## declaration interface + + def declare(self, key, type, default, + module=None, title=None, description=None, example=None): + """Declare a configuration key. + + A key cannot be declared multiple times unless it has the same type + annotation and default value. + + :param:`key` Configuration key as tuple + :param:`type` Value type definition + :param:`default` Default value + :param:`module` Declaring module + :param:`title` Reader friendly name + :param:`description` Verbose description of its effect + :param:`example` Usage example + + """ + if len(key) == 0: + raise errors.ProgrammingError('the config key must contain at least one item.') + + # FIXME: can't have a rule for a subkey + # e.g. ('session', ): String() and ('session', 'verbose'): Int() + key = tuple(key) + if key in self.config: + # declaration exists, check compatibility + if self.config[key].type == type and \ + self.config[key].default == default: + # types are compatible, set/overwrite values + self.config[key].modules = module + self.config[key].title = title + self.config[key].description = description + self.config[key].example = example + logger.warning(f'config schema: potentially overwriting key {key}') + else: + raise IncompatibleTypes(f'declaration of {key} violates a previous declaration') + + elif type.check(default): + self.config[key] = ConfigKey(key, type, default, module, title, description, example) + + else: + raise errors.ProgrammingError('default value violates value type specification') + + def declare_title(self, key, module, title, description=None): + """Declare a config section title. Section titles are only used for + documentation purposes. + + :param:`key` Configuration key as tuple + :param:`module` Declaring module + :param:`title` Reader friendly name + :param:`description` Verbose description of its effect + + """ + if len(key) == 0: + raise errors.ProgrammingError('the config key must contain at least one item.') + + key = tuple(key) + if key in self.titles: + self.titles[key].title = title + self.titles[key].modules = module + self.titles[key].description = description + logger.warn(f'config schema: potentially overwriting title {key}') + else: + self.titles[key] = ConfigTitle(key, title, module, description) + + +class ConfigTitle(abc.Hashable): + """Title and description of a config key. Used for documentation.""" + def __init__(self, key, title=None, module=None, description=None): + self._key = key + self._title = title + self._description = description + self._modules = {module} if module is not None else set() + + def __repr__(self): + return f'ConfigTitle({self.key}, {self.title})' + + def __eq__(self, other): + return isinstance(other, type(self)) and self._key == other._key + + def __hash__(self): + return hash((type(self), self._key)) + + @property + def branch(self): + """Return the branch.""" + return self._key[:-1] + + @property + def leaf(self): + """Return the leaf.""" + return self._key[-1] + + @property + def key(self): + """Return the key.""" + return self._key + + @property + def title(self): + """Return the key's title.""" + return self._title if self._title is not None else self.leaf + + @title.setter + def title(self, title): + """Overwrite the key's title.""" + if title is not None and title != '': + self._title = title + + @property + def description(self): + """Return the key's description.""" + return self._description if self._description is not None else '' + + @description.setter + def description(self, description): + """Overwrite the key's description.""" + if description is not None and description != '': + self._description = description + + @property + def modules(self): + """Return the module names that declared the key.""" + return self._modules + + @modules.setter + def modules(self, module): + """Add another declaring module.""" + if module is not None and module != '': + self._modules.add(module) + + +class ConfigKey(ConfigTitle): + """Define the type and default value of a configuration key.""" + def __init__(self, key, type, default, module=None, title=None, + description=None, example=None): + super(ConfigKey, self).__init__(key, title, module, description) + self._type = type + self._default = default + self._examples = {example} if example is not None else set() + + def __repr__(self): + return f'ConfigKey({self.key}, {self.type}, {self.default})' + + def __eq__(self, other): + return super(ConfigKey, self).__eq__(other) and \ + self._type == other._type and \ + self._default == other._default + + def __hash__(self): + return hash((super(ConfigKey, self).__hash__(), self._type, self._default)) + + def check(self, value): + """Return True if *value* adheres to the key's type specification.""" + return self.type.check(value) + + def backtrack(self, value): + """Return True if *value* matches the key's type, raises a TypeError otherwise.""" + self.type.backtrack(value, '.'.join(self.key)) + return True + + @property + def default(self): + """Return the default value.""" + return self._default + + @property + def type(self): + """Return the type definition.""" + return self._type + + @property + def example(self): + """Return an example value.""" + return ', '.join(self._examples) if len(self._examples) else self.type.example + + @example.setter + def example(self, example): + """Add more examples for the key.""" + if example is not None and example != '': + self._examples.add(example) + +## global instance + +schema = ConfigSchema() + +def declare(*args, **kwargs): + schema.declare(*args, **kwargs) + +def declare_title(*args, **kwargs): + schema.declare_title(*args, **kwargs) + +## EOF ## diff --git a/tagit/config/settings.json b/tagit/config/settings.json new file mode 100644 index 0000000..e0bb3cf --- /dev/null +++ b/tagit/config/settings.json @@ -0,0 +1,70 @@ +{ + "ui": { + "standalone": { + "keytriggers": [ + "ClipboardCopy", + "ClipboardPaste", + "CreateGroup", + "DissolveGroup", + "AddToGroup", + "MoveCursorUp", + "MoveCursorDown", + "MoveCursorLeft", + "MoveCursorRight", + "MoveCursorLast", + "MoveCursorFirst", + "NextPage", + "PreviousPage", + "ScrollDown", + "ScrollUp", + "ZoomIn", + "ZoomOut", + "Select", + "SelectAll", + "SelectNone", + "SelectMulti", + "SelectRange", + "AddToken", + "GoBack", + "GoForth", + "SearchByAddressOnce", + "AddTag", + "EditTag", + "OpenGroup", + "RepresentGroup", + "Search", + "ShowSelected", + "RemoveSelected", + "OpenExternal", + "ShowHelp" + ], + "buttondocks": { + "filter": [ + "AddToken", + "GoBack", + "GoForth" + ], + "navigation_left": [ + "MoveCursorFirst", + "PreviousPage", + "ScrollUp" + ], + "navigation_right": [ + "ScrollDown", + "NextPage", + "MoveCursorLast" + ], + "status": [ + "RotateLeft", + "DeleteObject", + "RotateRight" + ] + }, + "context": { + "root": [ + "CloseSessionAndExit" + ] + } + } + } +} diff --git a/tagit/config/settings.py b/tagit/config/settings.py new file mode 100644 index 0000000..21ab594 --- /dev/null +++ b/tagit/config/settings.py @@ -0,0 +1,473 @@ +"""Configuration storage. + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 +""" +# standard imports +from collections import abc +from copy import deepcopy +import io +import json +import os +import typing + +# tagit imports +from tagit.utils import errors, fst, is_list + +# inner-module imports +from . import types +from .schema import schema as global_schema +from .utils import key_starts_with, superkey_of, subkey_of + +# constants +INDENT = 4 + +# exports +__all__: typing.Sequence[str] = ( + 'Settings', + ) + + +## code ## + +class ConfigError(TypeError): pass + +class Settings(abc.MutableMapping, abc.Hashable, abc.Callable): + """Access and modify config keys in a dict-like manner. + + It's assumed that the schema might not be available for all config + elements. That's because it might be declared in code that is + not yet or will never be loaded. In such cases, any value is accepted. + If the schema is known, however, it is enforced. + """ + + ## construction + + def __init__(self, schema=None, prefix=None, data=None): + self.prefix = tuple(prefix) if prefix is not None else tuple() + self.schema = schema if schema is not None else global_schema + self.config = data if data is not None else dict() + + @classmethod + def Open(cls, source, schema=None, on_error='raise', clear_defaults=True): + schema = schema if schema is not None else global_schema + config_path = '' + # load source + if isinstance(source, dict): # dictionary + config = source + elif isinstance(source, str): # path or serialized + if os.path.exists(source): + config_path = os.path.realpath(source) + with open(source, 'r') as ifile: + config = json.load(ifile) + else: + config = json.loads(source) + elif isinstance(source, io.TextIOBase): # opened file + config = json.load(source) + else: + raise TypeError('expected dict, path, or file-like') + + # flatten and verify + data = None + if len(config) > 0: + data = cls.flatten_tree(config, schema, on_error=on_error) + if clear_defaults: + # filter defaults + data = {key: value + for key, value in data.items() + if key not in schema or value != schema[key].default} + + data['session', 'paths', 'config'] = config_path + return cls(schema=schema, data=data) + + def update(self, other): + for key, value in other.config.items(): + self.set(key, value) + return self + + def rebase(self, schema=None, on_error='raise', clear_defaults=True): + """Re-align the config with the current schema. + Should be done if the schema changes *after* the Settings was initialized. + Can also be used to enforce a new schema on the current config. + + Be aware that calling rebase will disconnect Settings instances from + each other. For example, this affects non-leaf key retrieval via + get such as cfg('session') + """ + schema = self.schema if schema is None else schema + # unroll + tree = dict() + for key, value in self.config.items(): + path, leaf = list(key[:-1]), key[-1] + # navigate through the path + branch = tree + while len(path): + curr = path.pop(0) + if curr not in branch: + branch[curr] = dict() + branch = branch[curr] + + branch[leaf] = value + + # flatten the unrolled config + flat = self.flatten_tree(tree, schema, on_error=on_error) + # remove defaults + if clear_defaults: + flat = {key: value + for key, value in flat.items() + if key not in schema or value != schema[key].default} + # set new schema and config + self.config = flat + self.schema = schema + + ## comparison + + def __eq__(self, other): + return isinstance(other, Settings) and \ + self.schema == other.schema and \ + self.config == other.config and \ + self.prefix == other.prefix + + def __hash__(self): + return hash((type(self), + self.prefix, + hash(self.schema), + hash(tuple(sorted(self.config.items(), key=fst))))) + + def __str__(self): + return str({k: v for k, v in self.config.items() if key_starts_with(k, self.prefix)}) + + def __repr__(self): + prefix = ','.join(self.prefix) + size_self = len([key for key in self.config if key_starts_with(key, self.prefix)]) + size_all = len(self) + return f'Settings(prefix=({prefix}), keys={size_self}, len={size_all})' + + ## conversion + + def clone(self): + return Settings(schema=self.schema, prefix=self.prefix, data=deepcopy(self.config)) + + @staticmethod + def flatten_tree(hdict, schema, on_error='raise', prefix=tuple()): + """Flattens a hierarchical dictionary by using schema information. + Returns a flat list of config keys and their values. + + If an invalid type was found and on_error is 'raise, a TypeError is raised. + Otherwise the invalid key is ignored. + """ + if len(hdict) == 0: + # not in schema, or passed the check + return {prefix: dict()} + + flat = dict() + for sub in hdict: + try: + key = prefix + (sub, ) + # check schema first, to preserve dict types + if key in schema and schema[key].backtrack(hdict[sub]): + # accept the value (also defaults!) + flat[key] = hdict[sub] + + elif isinstance(hdict[sub], dict): + flat.update(Settings.flatten_tree(hdict[sub], schema, on_error, key)) + + elif any(key_starts_with(k, key) for k in schema): + subkeys = [k[len(key):] for k in schema if key_starts_with(k, key)] + subkeys = ','.join('.'.join(k) for k in subkeys) + raise ConfigError( + f'found value {hdict[sub]} in {key}, expected keys ({subkeys})') + + else: + # terminal but not in schema; accept + flat[key] = hdict[sub] + + except TypeError as e: + if on_error == 'raise': + raise e + + return flat + + def to_tree(self, defaults=False): + """Return a nested dictionary with all config values. + If *defaults*, the schema defaults are included. + """ + tree = dict() + source = set(self.config.keys()) + if defaults: + source |= set(self.schema.keys()) + + for key in source: + if not key_starts_with(key, self.prefix): + continue + value = self.get(*key[len(self.prefix):]) + path, leaf = list(key[:-1]), key[-1] + path = path[len(self.prefix):] + # navigate through the path + branch = tree + while len(path): + curr = path.pop(0) + if curr not in branch: + branch[curr] = dict() + branch = branch[curr] + + branch[leaf] = value + + return tree + + def file_connected(self): + """Return True if the config is backed by a file.""" + return self('session', 'paths', 'config') is not None and \ + self('session', 'paths', 'config') != '' + + def save(self, uri=None): + """Save changes to a file at *uri*.""" + # pick defaults + uri = uri if uri is not None else self('session', 'paths', 'config') + if uri is None or uri == '': + raise ValueError('config saving requires a valid uri') + + # convert to tree + config = self.to_tree(defaults=False) + + # save to file + if isinstance(uri, io.TextIOBase): + json.dump(config, uri, indent=INDENT) + else: + with open(uri, 'w') as ofile: + json.dump(config, ofile, indent=INDENT) + + def diff(self, other): + """Return a config that includes only the keys which differ from *other*.""" + # keys in self that differ from other + config = {key: value + for key, value in self.config.items() + if key not in other.config or value != other.config[key] + } + # keys in other that differ from default + config.update({key: self.schema[key].default + for key, value in other.config.items() + if key not in self.config and \ + key in self.schema and \ + value != self.schema[key].default + }) + + return Settings(schema=self.schema, prefix=self.prefix, data=deepcopy(config)) + + + ## getting + + def __getitem__(self, key): + """Alias for *get*.""" + if is_list(key): + return self.get(*key) + else: + return self.get(key) + + def __call__(self, *key, default=None): + """Alias for *get*.""" + return self.get(*key, default=default) + + def get(self, *key, default=None): + key = self.prefix + key + + # known leaf + if key in self.config: + value = self.config[key] + if key in self.schema: + if self.schema[key].check(value): + return value + elif default is not None and self.schema[key].check(default): + return default + else: + return self.schema[key].default + else: + return value + + # unknown leaf + if key in self.schema: + if default is not None and self.schema[key].check(default): + return default + else: + return self.schema[key].default + + # branch + if any(key_starts_with(sub, key) for sub in self.config): + return Settings(schema=self.schema, prefix=key, data=self.config) + elif any(key_starts_with(sub, key) for sub in self.schema): + return Settings(schema=self.schema, prefix=key, data=self.config) + + if default is not None: + return default + + raise KeyError(key) + + ## checking + + def __contains__(self, key): + """Alias for *has*.""" + return self.has(*key) + + def has(self, *key): + key = self.prefix + key + + if key in self.config: + # key is a known leaf + return True + elif key in self.schema: + # key is an unknown leaf + return True + else: + # key might be a branch + for sub in self.config: + if key_starts_with(sub, key): + return True + + for sub in self.schema: + if key_starts_with(sub, key): + return True + + return False + + ## setting + + def __setitem__(self, key, value): + """Alias for *set*.""" + if is_list(key): + self.set(key, value) + else: + self.set((key, ), value) + + def set(self, key, value): + key = self.prefix + key + + if key in self.schema and self.schema[key].backtrack(value): + if self.schema[key].default != value: + self.config[key] = value + elif key in self.config: # value is default + # reset value to default, remove from config + del self.config[key] + # else: value was default but not present, ignore + + elif key in self.config: + self.config[key] = value + + elif isinstance(value, dict) and len(value) > 0: + # flatten value and set its items individually + subtree = self.flatten_tree(value, self.schema, prefix=key) + for subkey, subval in subtree.items(): + # defaults will be filtered by set + self.set(subkey[len(self.prefix):], subval) + + elif superkey_of(key, self.schema) or subkey_of(key, self.schema): + # schema violation in another branch + conflicts = {'.'.join(sub) + for sub in self.schema + if key_starts_with(sub, key) or key_starts_with(key, sub)} + raise ConfigError(f'{key} conflicts with schema keys {",".join(conflicts)}') + + elif superkey_of(key, self.config): + # it's allowed to overwrite dict-like config values + # Example: + # having defined session.paths.preview.files = 'somewhere' + # it's allowed to set session.paths.preview = {} + # It's admissible iff: + # * the value is an empty dict + # * no subkey is in the schema (already checked in the case above) + if value == dict(): + self.unset(*key) + self.config[key] = value + else: + conflicts = {'.'.join(sub) + for sub in self.schema + if key_starts_with(sub, key) or key_starts_with(key, sub)} + raise ConfigError(f'{key} conflicts with config keys {",".join(conflicts)}') + + elif subkey_of(key, self.config): + # it's allowed to overwrite dict-like config values + # Example: + # having defined session.paths.preview = {} + # it's allowed to set session.paths.preview.files = 'somewhere' + # It's admissible iff: + # * the superkey is an empty dict + # * no subkey of the superkey is in the schema + sups = [sup for sup in self.config if key_starts_with(key, sup)] + if len(sups) != 1: + # there can only be one super-key + raise errors.ProgrammingError(f'expected one superkey, found {len(sups)}') + + sup = sups[0] + if self.config[sup] == dict() and \ + len({sub for sub in self.schema if key_starts_with(sup, sub)}) == 0: + del self.config[sup] + self.config[key] = value + else: + # already have a superkey in the config that cannot be overwritten + conflicts = '.'.join(sup) + raise ConfigError(f'{key} conflicts with config keys {conflicts}') + + else: + self.config[key] = value + + return self + + ## removal + + def __delitem__(self, key): + """Alias for *unset*.""" + if is_list(key): + self.unset(*key) + else: + self.unset(key) + + def unset(self, *key): + key = self.prefix + key + + if key in self.config: + # key is a leaf + del self.config[key] + else: + # key might be a branch + subs = [sub for sub in self.config if key_starts_with(sub, key)] + for sub in subs: + del self.config[sub] + + return self + + ## iteration + + def __iter__(self): + """Alias for *keys*.""" + return self.keys() + + def keys(self): + for key in set(self.config.keys()) | set(self.schema.keys()): + if key_starts_with(key, self.prefix): + yield key[len(self.prefix):] + + def items(self): + for key in self.keys(): + yield key, self.get(*key) + + ## properties + + def __len__(self): + return len(list(self.keys())) + + +## config ## + +global_schema.declare(('session', 'verbose'), types.Unsigned(), 0, + __name__, 'Verbosity', 'Print additional information in various places of the application.') + +global_schema.declare(('session', 'debug'), types.Bool(), False, + __name__, 'Debug mode', 'Enable debug output and debug behaviour. Should be set to false in a productive environment.') + +global_schema.declare(('session', 'paths', 'config'), types.Path(), '', + __name__, 'Config path', "The path of the session's main configuration file. Is set automatically and for internal use only.") + +global_schema.declare(('storage', 'config', 'write_through'), types.Bool(), True, + __name__, 'Write-through', "Write the config to its file whenever it changes") + +## EOF ## diff --git a/tagit/config/types.py b/tagit/config/types.py new file mode 100644 index 0000000..3dc3d38 --- /dev/null +++ b/tagit/config/types.py @@ -0,0 +1,273 @@ +""" + +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, is_list + +# exports +__all__: typing.Sequence[str] = ( + 'ConfigTypeError', + # types + 'Any', + 'Bool', + 'Dict', + 'Enum', + 'Float', + 'Int', + 'Keybind', + 'List', + 'Numeric', + 'Path', + 'String', + 'Unsigned', + ) + +# TODO: Bounded int or range? (specify lo/hi bounds) +# TODO: File vs. Dir; existence condition? + +## code ## + +# base class + +class ConfigTypeError(TypeError): + """Raised if a type inconsistency is detected.""" + pass + +class ConfigType(object): + """A config type defines a constraint over admissible values in order to + perform a basic verification of user-entered config values. + """ + + # example values + example = '' + + # type description + description = '' + + def __str__(self): + return f'{type(self).__name__}' + + def __repr__(self): + return f'{type(self).__name__}()' + + def __eq__(self, other): + return isinstance(other, type(self)) + + def __hash__(self): + return hash(type(self)) + + def check(self, value): + """Return True if the *value* matches the type.""" + try: + self.backtrack(value, '') + return True + except ConfigTypeError: + return False + + def backtrack(self, value, key): + """Check *value* for errors. + Raises a ConfigTypeError with a detailed message if an inconsistency is detected. + """ + errors.abstract() + +# generic types + +class Any(ConfigType): + example = '1, "a", [1,2,"a"]' + description = 'Any type' + + def backtrack(self, value, key): + # accepts anything + pass + + +class Bool(ConfigType): + example = 'True, False' + description = 'Boolean' + + def backtrack(self, value, key): + if not isinstance(value, bool): + raise ConfigTypeError(f'found {value} in {key}, expected a boolean') + + +class Keybind(ConfigType): + example = '[("a", ["ctrl"], [])]' + description = 'A list of (key, required modifiers, excluded modifiers)-triples' + + def backtrack(self, value, key): + if not is_list(value): + raise ConfigTypeError(f'found {type(value)} in {key}, expected a list of bindings') + + modifiers = {'shift', 'alt', 'ctrl', 'cmd', 'altgr', 'rest', 'all'} + for idx, itm in enumerate(value): + if not is_list(itm) or len(itm) != 3: + raise ConfigTypeError(f'found {itm} in {key}[{idx}], expected a list of three') + + char, inc, exc = itm + if not isinstance(char, str) and \ + not isinstance(char, int) and \ + not isinstance(char, float): + raise ConfigTypeError( + f'found {char} in {key}[{idx}], expected a character or number') + if not is_list(inc) or not set(inc).issubset(modifiers): + mods = ','.join(modifiers) + raise ConfigTypeError(f'found {inc} in {key}[{idx}], expected some of ({mods})') + if not is_list(exc) or not set(exc).issubset(modifiers): + mods = ','.join(modifiers) + raise ConfigTypeError(f'found {exc} in {key}[{idx}], expected some of ({mods})') + + +# numeric types + +class Numeric(ConfigType): + pass + + +class Int(Numeric): + example = '-8, -1, 0, 1, 3' + description = 'Integer number' + + def backtrack(self, value, key): + if not isinstance(value, int): + raise ConfigTypeError(f'found {value} in {key}, expected an integer') + + +class Unsigned(Int): + example = '0, 1, 13, 32' + description = 'Non-negative integer number, including zero' + + def __str__(self): + return 'Unsigned int' + + def backtrack(self, value, key): + if not isinstance(value, int) or value < 0: + raise ConfigTypeError(f'found {value} in {key}, expeced an integer of at least zero') + + +class Float(Numeric): + example = '1.2, 3.4, 5, 6' + description = 'Integer or Decimal number' + + def backtrack(self, value, key): + if not isinstance(value, float) and not isinstance(value, int): + raise ConfigTypeError(f'found {value} in {key}, expected a number') + + +# string types + +class String(ConfigType): + example = '"hello world", "", "foobar"' + description = 'String' + + def backtrack(self, value, key): + if not isinstance(value, str): + raise ConfigTypeError(f'found {value} in {key}, expected a string') + + +class Path(String): + example = '"/tmp", "Pictures/trip", "~/.tagitrc"' + description = 'String, compliant with file system paths' + + +# compound types + +class Enum(ConfigType): + description = 'One out of a predefined set of values' + + @property + def example(self): + return ', '.join(str(o) for o in list(self.options)[:3]) + + def __init__(self, *options): + self.options = set(options[0] if len(options) == 1 and is_list(options[0]) else options) + + def __eq__(self, other): + return super(Enum, self).__eq__(other) and \ + self.options == other.options + + def __hash__(self): + return hash((super(Enum, self).__hash__(), tuple(self.options))) + + def __str__(self): + options = ', '.join(str(itm) for itm in self.options) + return f'One out of ({options})' + + def __repr__(self): + return f'{type(self).__name__}([{self.options}])' + + def backtrack(self, value, key): + try: + if value not in self.options: + raise Exception() + except Exception: + options = ','.join(str(itm) for itm in self.options) + raise ConfigTypeError(f'found {value} in {key}, expected one out of ({options})') + + +class List(ConfigType): + description = 'List of values' + + @property + def example(self): + return f'[{self.item_type.example}]' + + def __init__(self, item_type): + self.item_type = item_type + + def __eq__(self, other): + return super(List, self).__eq__(other) and \ + self.item_type == other.item_type + + def __hash__(self): + return hash((super(List, self).__hash__(), hash(self.item_type))) + + def __str__(self): + return f'List of {str(self.item_type)}' + + def __repr__(self): + return f'{type(self).__name__}({self.item_type})' + + def backtrack(self, value, key): + if not isinstance(value, list) and not isinstance(value, tuple): + raise ConfigTypeError(f'found {type(value)} in {key}, expected list') + for item in value: + self.item_type.backtrack(item, key) + + +class Dict(ConfigType): + example = '{"hello": "world"}; {"hello": 3}; {"hello": [1, 2, 3]}' + description = 'Map of keys/values' + + def __init__(self, key_type, value_type): + self.key_type = key_type + self.value_type = value_type + + def __eq__(self, other): + return super(Dict, self).__eq__(other) and \ + self.key_type == other.key_type and \ + self.value_type == other.value_type + + def __hash__(self): + return hash((super(Dict, self).__hash__(), hash(self.key_type), hash(self.value_type))) + + def __str__(self): + return f'Dict from {self.key_type} to {self.value_type}' + + + def __repr__(self): + return f'{type(self).__name__}({self.key_type}, {self.value_type})' + + def backtrack(self, value, key): + if not isinstance(value, dict): + raise ConfigTypeError(f'found {type(value)} in {key}, expected a dict') + for subkey, subval in value.items(): + self.key_type.backtrack(subkey, str(key) + '.' + str(subkey)) + self.value_type.backtrack(subval, str(key) + '.' + str(subkey)) + +## EOF ## diff --git a/tagit/config/user-defaults.json b/tagit/config/user-defaults.json new file mode 100644 index 0000000..b76ef2b --- /dev/null +++ b/tagit/config/user-defaults.json @@ -0,0 +1,142 @@ +{ + "session": { + "first_start": false, + "paths": { + "searchlog": "~/.tagit.log" + } + }, + "storage": { + "index": { + "preview_size": [ + 50, + 200, + 400 + ] + } + }, + "ui": { + "standalone": { + "window_size": "1024x768", + "browser": { + "maxrows": 8, + "maxcols": 8 + }, + "buttondocks": { + "sidebar_left": [ + "Menu", + "ShowDashboard", + "AddTag", + "EditTag", + "CreateGroup", + "DissolveGroup", + "SelectAll", + "SelectNone", + "SelectInvert", + "SelectAdditive", + "SelectSubtractive", + "SelectSingle", + "SelectMulti", + "SelectRange" + ] + }, + "tabs": { + "max": 2 + }, + "tiledocks": { + "dashboard": { + "Buttons": { + "buttons": [ + "ShowBrowsing", + "CreateSession", + "CreateTempSession", + "LoadSession", + "ReloadSession", + "ImportObjects", + "SaveSession", + "SaveSessionAs", + "ItemExport", + "UpdateSelectedObjects", + "SyncSelectedObjects", + "ShowHelp", + "ShowSettings" + ] + }, + "LibSummary": {}, + "Hints": {}, + "TagHistogram": {}, + "Tagcloud": {}, + "Searchtree": {} + }, + "sidebar_right": { + "Info": {}, + "CursorTags": {}, + "Venn": {} + } + }, + "search": { + "sort_blacklist": [ + "entity", + "flash", + "latitude", + "longitude", + "mime", + "author", + "camera", + "attributes" + ] + }, + "context": { + "app": [ + "ShowSettings", + "ShowHelp", + "ShowConsole" + ], + "session": [ + "SaveSession", + "SaveSessionAs", + "ItemExport", + "ImportObjects" + ], + "search": [ + "ShowSelected", + "RemoveSelected" + ], + "browser": [ + "ZoomIn", + "ZoomOut" + ], + "select": [ + "SelectAll", + "SelectNone", + "SelectInvert", + "SelectSingle", + "SelectMulti", + "SelectRange", + "SelectAdditive", + "SelectSubtractive" + ], + "clipboard": [ + "ClipboardCopy", + "ClipboardPaste" + ], + "tagging": [ + "AddTag", + "EditTag", + "SetRank1", + "SetRank3", + "SetRank5" + ], + "grouping": [ + "CreateGroup", + "DissolveGroup", + "AddToGroup", + "RepresentGroup", + "RemoveFromGroup" + ], + "root": [ + "CloseSessionAndExit" + ] + } + } + } +} diff --git a/tagit/config/utils.py b/tagit/config/utils.py new file mode 100644 index 0000000..948f53a --- /dev/null +++ b/tagit/config/utils.py @@ -0,0 +1,104 @@ +"""Configuration system utilities. + +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 import_all + +# inner-module imports +from . import types + +# exports +__all__: typing.Sequence[str] = ( + 'key_starts_with', + 'schema_key_sort', + 'schema_to_rst', + 'subkey_of', + 'superkey_of', + ) + + +## code ## + +def key_starts_with(key, prefix): + """Return whether a config *key* starts with (is a subclass of) *prefix*.""" + return key[:len(prefix)] == prefix + +def subkey_of(key, pool): + """Return True if *key* is at a lower level than some key in *pool*. + Example: session.debug is a subkey of session. + """ + for sup in pool: + if key_starts_with(key, sup): + return True + return False + +def superkey_of(key, pool): + """Return True if *key* is at a higher level than some key in *pool*. + Example: session is a superkey of session.debug. + """ + for sub in pool: + if key_starts_with(sub, key): + return True + return False + +def schema_key_sort(schema): + """Return a comparison function for sorting schema config or title keys. + To be used in sorted or sort as key function. + + >>> sorted(schema.keys(titles=True), key=schema_keys_sort(schema)) + + """ + def cmp(key): + """Return an unambiguous representation of schema config or title keys.""" + return ('.'.join(key[:-1]) + '..' + key[-1]) \ + if not schema.is_title(key) \ + else ('.'.join(key) + '..') + + return cmp + +def schema_to_rst(schema, print_modules=False, no_import=False): + """Creates a documentation page in ReST of the config schema. + Calling this method with *no_import* set to False imports all + tagit submodules. + """ + # import all modules + if not no_import: + import tagit + import_all(tagit, exclude={'.*\.external'}) + + header = '=-^~"' + + known_sections = set() + for key in sorted(schema.keys(titles=True), key=schema_key_sort(schema)): + # print headings + for idx, sec in enumerate(key): + heading = '.'.join(key[:idx+1]) + if heading not in known_sections: + print('') + print(heading) + print(header[idx] * len(heading)) + known_sections.add(heading) + + if schema.is_title(key): + print(schema.get_title(key).description + '\n') + + else: + print(schema[key].description + '\n') + + print(f':Format: {str(schema[key].type)} ({schema[key].example})') + print(f':Default: {schema[key].default}') + + if isinstance(schema[key].type, types.Enum): + print(f':Options: {schema[key].type.options}') + + if print_modules: + modules = ', '.join(f'`{str(m)}`_' for m in schema[key].modules) + print(f':Modules: {modules}') + +## EOF ## diff --git a/tagit/utils/__init__.py b/tagit/utils/__init__.py index d5a8efe..d143034 100644 --- a/tagit/utils/__init__.py +++ b/tagit/utils/__init__.py @@ -9,6 +9,7 @@ import typing # inner-module imports from . import bsfs +from .shared import * # FIXME: port properly # exports __all__: typing.Sequence[str] = ( diff --git a/tagit/utils/errors.py b/tagit/utils/errors.py new file mode 100644 index 0000000..1bed670 --- /dev/null +++ b/tagit/utils/errors.py @@ -0,0 +1,52 @@ +"""Module-wide errors. + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2018 +""" +# exports +__all__ = ( + 'EmptyFileError', + 'LoaderError', + 'NotAFileError', + 'ProgrammingError', + 'UserError', + 'abstract', + ) + + +## code ## + +def abstract(): + """Marks that a method has to be implemented in a child class.""" + raise NotImplementedError('abstract method that must be implemented in a subclass') + +class ProgrammingError(Exception): + """Reached a program state that shouldn't be reachable.""" + pass + +class UserError(ValueError): + """Found an illegal value that was specified directly by the user.""" + pass + +class NotAFileError(OSError): + """A file-system object is not a regular file.""" + pass + +class EmptyFileError(OSError): + """A file is unexpectedly empty.""" + pass + +class LoaderError(Exception): + """Failed to load or initialize a critical data structure.""" + pass + +class ParserFrontendError(Exception): + """Generic parser frontend error.""" + pass + +class ParserBackendError(Exception): + """Generic parser backend error.""" + pass + +## EOF ## diff --git a/tagit/utils/shared.py b/tagit/utils/shared.py new file mode 100644 index 0000000..13ffd2a --- /dev/null +++ b/tagit/utils/shared.py @@ -0,0 +1,63 @@ +# FIXME: port properly! +"""Shared functionality. + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 +""" +# standard imports +import logging +import pkgutil +import re +import typing + +# exports +__all__ = ('import_all', ) + + +## code ## + +# exports +__all__: typing.Sequence[str] = ( + 'fst', + 'is_list', + 'import_all', + ) + + +## code ## + +logger = logging.getLogger(__name__) + +fst = lambda lst: lst[0] + +def is_list(cand): + """Return true if *cand* is a list, a set, or a tuple""" + return isinstance(cand, list) or isinstance(cand, set) or isinstance(cand, tuple) + +def import_all(module, exclude=None, verbose=False): + """Recursively import all submodules of *module*. + *exclude* is a set of submodule names which will + be omitted. With *verbose*, all imports are logged + with level info. Returns all imported modules. + + >>> import tagit + >>> import_all(tagit, exclude={'tagit.shared.external'}) + + """ + exclude = set([] if exclude is None else exclude) + imports = [] + for importer, name, ispkg in pkgutil.iter_modules(module.__path__, module.__name__ + '.'): + if ispkg and all(re.match(exl, name) is None for exl in exclude): + if verbose: + logger.info(f'importing: {name}') + try: + module = __import__(name, fromlist='dummy') + imports.append(module) + imports += import_all(module, exclude, verbose) + except Exception as e: + logger.error(f'importing: {name}') + + return imports + +## 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/actions/__init__.py | 131 ++++ tagit/actions/filter.kv | 41 ++ tagit/actions/filter.py | 317 ++++++++++ tagit/actions/grouping.kv | 27 + tagit/actions/grouping.py | 257 ++++++++ tagit/dialogues/__init__.py | 59 ++ tagit/external/__init__.py | 15 + tagit/external/kivy_garden/__init__.py | 0 tagit/external/kivy_garden/contextmenu/__init__.py | 11 + tagit/external/kivy_garden/contextmenu/_version.py | 1 + tagit/external/kivy_garden/contextmenu/app_menu.kv | 25 + tagit/external/kivy_garden/contextmenu/app_menu.py | 118 ++++ .../kivy_garden/contextmenu/context_menu.kv | 125 ++++ .../kivy_garden/contextmenu/context_menu.py | 287 +++++++++ tagit/external/setproperty/README.md | 5 + tagit/external/setproperty/__init__.py | 3 + tagit/external/setproperty/setproperty.pxd | 9 + tagit/external/setproperty/setproperty.pyx | 125 ++++ tagit/external/setproperty/setup.py | 6 + tagit/external/setproperty/test.py | 62 ++ tagit/external/tooltip.kv | 12 + tagit/external/tooltip.py | 67 ++ tagit/tiles/__init__.py | 61 ++ tagit/tiles/decoration.kv | 58 ++ tagit/tiles/decoration.py | 68 +++ tagit/utils/__init__.py | 2 + tagit/utils/builder.py | 82 +++ tagit/utils/frame.py | 84 +++ tagit/utils/shared.py | 77 ++- tagit/utils/time.py | 63 ++ 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 ++ 49 files changed, 4920 insertions(+), 21 deletions(-) create mode 100644 tagit/actions/__init__.py create mode 100644 tagit/actions/filter.kv create mode 100644 tagit/actions/filter.py create mode 100644 tagit/actions/grouping.kv create mode 100644 tagit/actions/grouping.py create mode 100644 tagit/dialogues/__init__.py create mode 100644 tagit/external/__init__.py create mode 100644 tagit/external/kivy_garden/__init__.py create mode 100644 tagit/external/kivy_garden/contextmenu/__init__.py create mode 100644 tagit/external/kivy_garden/contextmenu/_version.py create mode 100644 tagit/external/kivy_garden/contextmenu/app_menu.kv create mode 100644 tagit/external/kivy_garden/contextmenu/app_menu.py create mode 100644 tagit/external/kivy_garden/contextmenu/context_menu.kv create mode 100644 tagit/external/kivy_garden/contextmenu/context_menu.py create mode 100644 tagit/external/setproperty/README.md create mode 100644 tagit/external/setproperty/__init__.py create mode 100644 tagit/external/setproperty/setproperty.pxd create mode 100644 tagit/external/setproperty/setproperty.pyx create mode 100644 tagit/external/setproperty/setup.py create mode 100644 tagit/external/setproperty/test.py create mode 100644 tagit/external/tooltip.kv create mode 100644 tagit/external/tooltip.py create mode 100644 tagit/tiles/__init__.py create mode 100644 tagit/tiles/decoration.kv create mode 100644 tagit/tiles/decoration.py create mode 100644 tagit/utils/builder.py create mode 100644 tagit/utils/frame.py create mode 100644 tagit/utils/time.py 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') diff --git a/tagit/actions/__init__.py b/tagit/actions/__init__.py new file mode 100644 index 0000000..24524b4 --- /dev/null +++ b/tagit/actions/__init__.py @@ -0,0 +1,131 @@ +""" + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 +""" +# standard imports +import typing + +# tagit imports +from tagit.utils.builder import BuilderBase + +# inner-module imports +#from . import browser +from . import filter +from . import grouping +#from . import library +#from . import misc +#from . import objects +#from . import planes +#from . import search +#from . import session +#from . import tabs + +# exports +__all__: typing.Sequence[str] = ( + 'ActionBuilder', + ) + + +## code ## + +class ActionBuilder(BuilderBase): + _factories = { + ## browser + #'NextPage': browser.NextPage, + #'PreviousPage': browser.PreviousPage, + #'ScrollDown': browser.ScrollDown, + #'ScrollUp': browser.ScrollUp, + #'JumpToPage': browser.JumpToPage, + #'SetCursor': browser.SetCursor, + #'ZoomIn': browser.ZoomIn, + #'ZoomOut': browser.ZoomOut, + #'JumpToCursor': browser.JumpToCursor, + #'MoveCursorFirst': browser.MoveCursorFirst, + #'MoveCursorLast': browser.MoveCursorLast, + #'MoveCursorUp': browser.MoveCursorUp, + #'MoveCursorDown': browser.MoveCursorDown, + #'MoveCursorLeft': browser.MoveCursorLeft, + #'MoveCursorRight': browser.MoveCursorRight, + #'SelectRange': browser.SelectRange, + #'SelectAdditive': browser.SelectAdditive, + #'SelectSubtractive': browser.SelectSubtractive, + #'SelectMulti': browser.SelectMulti, + #'SelectSingle': browser.SelectSingle, + #'SelectAll': browser.SelectAll, + #'SelectNone': browser.SelectNone, + #'SelectInvert': browser.SelectInvert, + #'Select': browser.Select, + ## filter + #'AddToken': filter.AddToken, + #'RemoveToken': filter.RemoveToken, + #'SetToken': filter.SetToken, + #'EditToken': filter.EditToken, + #'GoBack': filter.GoBack, + #'GoForth': filter.GoForth, + #'JumpToToken': filter.JumpToToken, + #'SearchByAddressOnce': filter.SearchByAddressOnce, + #'SearchmodeSwitch': filter.SearchmodeSwitch, + ## grouping + #'CreateGroup': grouping.CreateGroup, + #'DissolveGroup': grouping.DissolveGroup, + #'AddToGroup': grouping.AddToGroup, + #'OpenGroup': grouping.OpenGroup, + #'RepresentGroup': grouping.RepresentGroup, + #'RemoveFromGroup': grouping.RemoveFromGroup, + ## library + #'AutoUpdate': library.AutoUpdate, + #'UpdateSelectedObjects': library.UpdateSelectedObjects, + #'UpdateObjects': library.UpdateObjects, + #'AutoImport': library.AutoImport, + #'ImportObjects': library.ImportObjects, + #'AutoSync': library.AutoSync, + #'SyncSelectedObjects': library.SyncSelectedObjects, + #'SyncObjects': library.SyncObjects, + #'ItemExport': library.ItemExport, + ## misc + #'ShellDrop': misc.ShellDrop, + #'OpenExternal': misc.OpenExternal, + #'Menu': misc.Menu, + #'ShowConsole': misc.ShowConsole, + #'ShowHelp': misc.ShowHelp, + #'ShowSettings': misc.ShowSettings, + #'ClipboardCopy': misc.ClipboardCopy, + #'ClipboardPaste': misc.ClipboardPaste, + ## objects + #'RotateLeft': objects.RotateLeft, + #'RotateRight': objects.RotateRight, + #'DeleteObject': objects.DeleteObject, + #'AddTag': objects.AddTag, + #'EditTag': objects.EditTag, + #'SetRank1': objects.SetRank1, + #'SetRank2': objects.SetRank2, + #'SetRank3': objects.SetRank3, + #'SetRank4': objects.SetRank4, + #'SetRank5': objects.SetRank5, + ## planes + #'ShowDashboard': planes.ShowDashboard, + #'ShowBrowsing': planes.ShowBrowsing, + #'ShowCodash': planes.ShowCodash, + ## search + #'Search': search.Search, + #'ShowSelected': search.ShowSelected, + #'RemoveSelected': search.RemoveSelected, + #'SortKey': search.SortKey, + #'SortOrder': search.SortOrder, + ## session + #'LoadSession': session.LoadSession, + #'SaveSession': session.SaveSession, + #'SaveSessionAs': session.SaveSessionAs, + #'CreateSession': session.CreateSession, + #'CreateTempSession': session.CreateTempSession, + #'ReloadSession': session.ReloadSession, + #'CloseSessionAndExit': session.CloseSessionAndExit, + ## tabs + #'AddTab': tabs.AddTab, + #'CloseTab': tabs.CloseTab, + #'SwitchTab': tabs.SwitchTab, + } + +## EOF ## diff --git a/tagit/actions/filter.kv b/tagit/actions/filter.kv new file mode 100644 index 0000000..2fce0e2 --- /dev/null +++ b/tagit/actions/filter.kv @@ -0,0 +1,41 @@ +#:import resource_find kivy.resources.resource_find + +: + source: resource_find('atlas://filter/address_once') + tooltip: 'Open the filters in address mode for a single edit' + +: + source: resource_find('atlas://filter/set_token') + tooltip: 'Set all filters from a text query' + +: + source: resource_find('atlas://filter/add') + tooltip: 'Add a tag filter' + +: + source: resource_find('atlas://filter/edit_token') + tooltip: 'Edit a filter token' + +: + source: resource_find('atlas://filter/remove_token') + tooltip: 'Remove a filter token' + +: + source: resource_find('atlas://filter/go_back') + tooltip: 'Previous view' + +: + source: resource_find('atlas://filter/go_forth') + tooltip: 'Next view' + +: + source: resource_find('atlas://filter/jump') + tooltip: 'Jump to filter token' + +: + source_address: resource_find('atlas://filter/address') + source_shingles: resource_find('atlas://filter/shingles') + source: self.source_shingles + tooltip: 'Switch between adress and shingles display' + +## EOF ## diff --git a/tagit/actions/filter.py b/tagit/actions/filter.py new file mode 100644 index 0000000..3702879 --- /dev/null +++ b/tagit/actions/filter.py @@ -0,0 +1,317 @@ +""" + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 +""" +# standard imports +import os + +# kivy imports +from kivy.lang import Builder +import kivy.properties as kp + +# tagit imports +from tagit import config +from tagit import dialogues +#from tagit.parsing import ParserError # FIXME: mb/port +#from tagit.parsing.search import ast_from_string, ast_to_string, ast # FIXME: mb/port +#from tagit.storage.base import ns # FIXME: mb/port +from tagit.utils import Frame +from tagit.widgets.bindings import Binding +from tagit.widgets.filter import FilterAwareMixin + +# inner-module imports +from .action import Action + +# exports +__all__ = [] + + +## code ## + +# load kv +Builder.load_file(os.path.join(os.path.dirname(__file__), 'filter.kv')) + +# classes + +class SearchByAddressOnce(Action): + """Open the filters in address mode for a single edit""" + text = kp.StringProperty('Inline edit') + + def ktrigger(self, evt): + return Binding.check(evt, self.cfg('bindings', 'filter', 'edit_once')) + + def apply(self): + self.root.filter.show_address_once() + + +class SetToken(Action): + """Set all filters from a text query.""" + text = kp.StringProperty('Set tokens') + + def apply(self, text): + with self.root.filter as filter: + try: + # parse filter into tokens + tokens = list(ast_from_string(text)) + + # grab current frame + filter.f_head.append(self.root.browser.frame) + + # keep frames for tokens that didn't change + # create a new frame for changed (new or modified) tokens + frames = [filter.f_head[0]] + for tok in tokens: + if tok in filter.t_head: # t_head frames have precedence + frame = filter.f_head[filter.t_head.index(tok) + 1] + elif tok in filter.t_tail: + frame = filter.f_tail[filter.t_tail.index(tok)] + else: + frame = Frame() + frames.append(frame) + # use the last frame as current one + self.root.browser.frame = frames.pop() + + # set tokens + filter.t_head = tokens + filter.t_tail = [] + # set frames + filter.f_head = frames + filter.f_tail = [] + + except ParserError as e: + dialogues.Error(text=f'syntax error: {e}').open() + + +class AddToken(Action): + """Show a dialogue for adding a filter.""" + text = kp.StringProperty('Add filter') + + def ktrigger(self, evt): + return Binding.check(evt, self.cfg('bindings', 'filter', 'add_token')) + + def apply(self, token=None): + if token is None: + sugg = {node.label for node in self.root.session.storage.all(ns.tagit.storage.base.Tag)} + dlg = dialogues.TokenEdit(suggestions=sugg) + dlg.bind(on_ok=lambda wx: self.add_from_string(wx.text)) + dlg.open() + elif isinstance(token, str): + self.add_from_string(token) + elif isinstance(token, ast.ASTNode): + self.add_token([token]) + + def add_from_string(self, text): + try: + self.add_token(ast_from_string(text)) + except ParserError as e: + dialogues.Error(text=f'syntax error: {e}').open() + + def add_token(self, tokens): + with self.root.filter as filter: + tokens = [tok for tok in tokens if tok not in filter.t_head] + for tok in tokens: + # add token and frame + filter.t_head.append(tok) + filter.f_head.append(self.root.browser.frame) + if len(tokens): + # clear tails + filter.t_tail = [] + filter.f_tail = [] + # issue new frame + self.root.browser.frame = Frame() + + +class EditToken(Action): + """Show a dialogue for editing a filter.""" + text = kp.StringProperty('Edit token') + + def apply(self, token): + sugg = {node.label for node in self.root.session.storage.all(ns.tagit.storage.base.Tag)} + text = ast_to_string(token) + dlg = dialogues.TokenEdit(text=text, suggestions=sugg) + dlg.bind(on_ok=lambda obj: self.on_ok(token, obj)) + dlg.open() + + def on_ok(self, token, obj): + with self.root.filter as filter: + try: + tokens_from_text = ast_from_string(obj.text) + except ParserError as e: + dialogues.Error(text=f'Invalid token: {e}').open() + return + + # TODO: Check if this can be simplified + keep = False + for tok in tokens_from_text: + if tok == token: # don't remove if the token hasn't changed + keep = True + + if token in filter.t_head and tok not in filter.t_head: + # insert after token into t_head + idx = filter.t_head.index(token) + frame = filter.f_head[idx] + filter.t_head.insert(idx + 1, tok) + filter.f_head.insert(idx + 1, frame.copy()) + # remove from t_tail + if tok in filter.t_tail: + idx = filter.t_tail.index(tok) + filter.f_tail.pop(idx) + filter.t_tail.pop(idx) + elif token in filter.t_tail and tok not in filter.t_tail: + # insert after token into t_tail + idx = filter.t_tail.index(token) + frame = filter.f_tail[idx] + filter.t_tail.insert(idx + 1, tok) + filter.f_tail.insert(idx + 1, frame.copy()) + # remove from t_head + if tok in filter.t_head: + idx = filter.t_head.index(tok) + filter.t_head.pop(idx) + filter.f_head.pop(idx) + + # remove original token + if not keep and token in filter.t_head: + idx = filter.t_head.index(token) + filter.t_head.pop(idx) + filter.f_head.pop(idx) + if not keep and token in filter.t_tail: + idx = filter.t_tail.index(token) + filter.t_tail.pop(idx) + filter.f_tail.pop(idx) + + +class RemoveToken(Action): + """Remove a filter.""" + text = kp.StringProperty('Remove token') + + def apply(self, token): + with self.root.filter as filter: + if token in filter.t_head: + idx = filter.t_head.index(token) + # remove frame + if idx < len(filter.t_head) - 1: + filter.f_head.pop(idx + 1) + self.root.browser.frame = Frame() + else: + self.root.browser.frame = filter.f_head.pop() + # remove token + filter.t_head.remove(token) + + if token in filter.f_tail: + filter.f_tail.pop(filter.t_tail.index(token)) + filter.t_tail.remove(token) + + +class GoBack(Action): + """Remove the rightmost filter from the search.""" + text = kp.StringProperty('Previous search') + + def ktrigger(self, evt): + return Binding.check(evt, self.cfg('bindings', 'filter', 'go_back')) + + def apply(self, n_steps=1): + with self.root.filter as filter: + for _ in range(n_steps): + if len(filter.t_head) > 0: + # move tokens + filter.t_tail.insert(0, filter.t_head.pop(-1)) + # move frames + filter.f_tail.insert(0, self.root.browser.frame) + self.root.browser.frame = filter.f_head.pop(-1) + + +class GoForth(Action): + """Add the rightmost filter to the search""" + text = kp.StringProperty('Next search') + + def ktrigger(self, evt): + return Binding.check(evt, self.cfg('bindings', 'filter', 'go_forth')) + + def apply(self, n_steps=1): + with self.root.filter as filter: + for _ in range(n_steps): + if len(filter.t_tail) > 0: + # move tokens + filter.t_head.append(filter.t_tail.pop(0)) + # move frames + filter.f_head.append(self.root.browser.frame) + self.root.browser.frame = filter.f_tail.pop(0) + + +class JumpToToken(Action): + """Jump to a filter token.""" + text = kp.StringProperty('Jump to token') + + def apply(self, token): + filter = self.root.filter + if token == filter.t_head[-1]: + pass + elif token in filter.t_head: # go + self.root.trigger('GoBack', len(filter.t_head) - filter.t_head.index(token) - 1) + elif token in filter.t_tail: + self.root.trigger('GoForth', filter.t_tail.index(token) + 1) + + +class SearchmodeSwitch(Action, FilterAwareMixin): + """Switch between shingle and address search bar display.""" + text = kp.StringProperty('Toggle searchbar mode') + + def on_root(self, wx, root): + Action.on_root(self, wx, root) + FilterAwareMixin.on_root(self, wx, root) + + def on_searchmode(self, filter, searchmode): + if self._image is not None: + if searchmode == filter.MODE_ADDRESS: + self._image.source = self.source_address + else: + self._image.source = self.source_shingles + + def on_filter(self, wx, filter): + # remove old binding + if self.filter is not None: + self.filter.unbind(searchmode=self.on_searchmode) + # add new binding + self.filter = filter + if self.filter is not None: + self.filter.bind(searchmode=self.on_searchmode) + self.on_searchmode(self.filter, self.filter.searchmode) + + def __del__(self): + if self.filter is not None: + self.filter.unbind(searchmode=self.on_searchmode) + self.filter = None + + def apply(self): + with self.root.filter as filter: + if filter.searchmode == filter.MODE_SHINGLES: + filter.searchmode = filter.MODE_ADDRESS + else: + filter.searchmode = filter.MODE_SHINGLES + + +## config ## + +# keybindings + +config.declare(('bindings', 'filter', 'add_token'), + config.Keybind(), Binding.simple('k', Binding.mCTRL, Binding.mREST), # Ctrl + k + __name__, AddToken.text.defaultvalue, AddToken.__doc__) + +config.declare(('bindings', 'filter', 'go_forth'), + config.Keybind(), Binding.simple(Binding.RIGHT, Binding.mALT, Binding.mREST), # Alt + right + __name__, GoForth.text.defaultvalue, GoForth.__doc__) + +config.declare(('bindings', 'filter', 'edit_once'), config.Keybind(), + Binding.multi(('l', Binding.mCTRL, Binding.mREST), + ('/', Binding.mREST, Binding.mALL)), # Ctrl + l, / + __name__, SearchByAddressOnce.text.defaultvalue, SearchByAddressOnce.__doc__) + +config.declare(('bindings', 'filter', 'go_back'), config.Keybind(), + Binding.multi((Binding.BACKSPACE, Binding.mCTRL, Binding.mREST), + (Binding.LEFT, Binding.mALT, Binding.mREST)), # Ctrl + backspace, Alt + left + __name__, GoBack.text.defaultvalue, GoBack.__doc__) + +## EOF ## diff --git a/tagit/actions/grouping.kv b/tagit/actions/grouping.kv new file mode 100644 index 0000000..9135c55 --- /dev/null +++ b/tagit/actions/grouping.kv @@ -0,0 +1,27 @@ +#:import resource_find kivy.resources.resource_find + +: + source: resource_find('atlas://grouping/create') + tooltip: 'Group items' + +: + source: resource_find('atlas://grouping/ungroup') + tooltip: 'Ungroup items' + +: + source: resource_find('atlas://grouping/add') + tooltip: 'Add items to group' + +: + source: resource_find('atlas://grouping/group') + tooltip: 'Open Group' + +: + source: resource_find('atlas://grouping/represent') + tooltip: 'Make group representative' + +: + source: resource_find('atlas://grouping/remove') + tooltip: 'Remove from group' + +## EOF ## diff --git a/tagit/actions/grouping.py b/tagit/actions/grouping.py new file mode 100644 index 0000000..eddaeb6 --- /dev/null +++ b/tagit/actions/grouping.py @@ -0,0 +1,257 @@ +""" + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 +""" +# standard imports +import logging +import os +import random + +# kivy imports +from kivy.lang import Builder +import kivy.properties as kp + +# tagit imports +from tagit import config, dialogues +#from tagit.parsing.search import ast # FIXME: mb/port +#from tagit.storage.broker import Representative # FIXME: mb/port +from tagit.widgets import Binding +from tagit.utils import Frame + +# inner-module imports +from .action import Action + +# exports +__all__ = [] + + +## code ## + +logger = logging.getLogger(__name__) + +# load kv +Builder.load_file(os.path.join(os.path.dirname(__file__), 'grouping.kv')) + +# classes +class CreateGroup(Action): + """Create a group from selected items.""" + text = kp.StringProperty('Group items') + + def ktrigger(self, evt): + return Binding.check(evt, self.cfg('bindings', 'grouping', 'create')) + + def apply(self): + if self.cfg('ui', 'standalone', 'grouping', 'autoname'): + self.create_group() + else: + dlg = dialogues.SimpleInput() + dlg.bind(on_ok=lambda wx: self.create_group(wx.text)) + dlg.open() + + def create_group(self, label=None): + if len(self.root.browser.selection) > 1: + with self.root.browser as browser, \ + self.root.session as session: + + # create group + grp = Group.Create() + if label is not None: + grp.label = label + + # add items to group + ents = self.root.session.storage.entities(browser.unfold(browser.selection)) + ents.group += grp + + # select a random representative + grp.represented_by = random.choice(ents) + + # set selection and cursor to representative + # the representative will become valid after the search was re-applied + browser.selection.clear() + browser.selection.add(grp.represented_by) + browser.cursor = rep + + # notification + logger.info(f'Grouped {len(items)} items') + + # change event + session.dispatch('on_predicate_modified', 'group', items, {grp}) + + # jump to cursor + # needs to be done *after* the browser was updated + self.root.trigger('JumpToCursor') + + +class DissolveGroup(Action): + """Dissolve the selected group.""" + text = kp.StringProperty('Dissolve group') + + def ktrigger(self, evt): + return Binding.check(evt, self.cfg('bindings', 'grouping', 'ungroup')) + + def apply(self): + with self.root.browser as browser, \ + self.root.session as session: + cursor = browser.cursor + if cursor is not None and cursor in browser.folds: + # remove tag from items + items = list(cursor.members()) + #ents = ... + #grp = ... + #ents.group -= grp + for obj in items: + obj.group -= [cursor.represents()] # FIXME + + # FIXME: fix cursor and selection + # cursor: leave at item that was the representative + # selection: leave as is, select all group members if the cursor was selected + browser.frame = Frame() + + # notification + logger.info(f'Ungrouped {len(items)} items') + + # change event + session.dispatch('on_predicate_modified', 'group', items, {cursor.represents()}) + + self.root.trigger('JumpToCursor') + + +class AddToGroup(Action): + """Add an item to a group.""" + text = kp.StringProperty('Add to group') + + def ktrigger(self, evt): + return Binding.check(evt, self.cfg('bindings', 'grouping', 'add')) + + def apply(self): + with self.root.browser as browser, \ + self.root.session as session: + cursor = browser.cursor + if cursor is not None and cursor in browser.folds: + items = browser.unfold(browser.selection) + for obj in items: + if obj == cursor: + # don't add group to itself + continue + obj.group += [cursor.represents()] # FIXME: Not quite sure how to handle this + + # all selected items will be folded, hence it becomes empty + if cursor in browser.selection: + browser.selection = {cursor} + else: + browser.selection.clear() + + # change event + session.dispatch('on_predicate_modified', 'group', items, {cursor.represents()}) + + +class OpenGroup(Action): + """Show the items of the selected group.""" + text = kp.StringProperty('Open group') + + def ktrigger(self, evt): + return Binding.check(evt, self.cfg('bindings', 'grouping', 'open')) + + def apply(self, cursor=None): + if cursor is None: + cursor = self.root.browser.cursor + if cursor is not None and cursor in self.root.browser.folds: + token = ast.Token('group', ast.SetInclude(cursor.represents())) + self.root.trigger('AddToken', token) + + +class RepresentGroup(Action): + """Make the currently selected item the representative of the current group.""" + text = kp.StringProperty('Represent') + + def ktrigger(self, evt): + return Binding.check(evt, self.cfg('bindings', 'grouping', 'represent')) + + def apply(self): + with self.root.browser as browser, \ + self.root.filter as filter, \ + self.root.session as session: + if browser.cursor is not None \ + and len(filter.t_head) > 0 \ + and filter.t_head[-1].predicate() == 'group': + # we know that the cursor is part of the group, since it matches the filter. + guid = filter.t_head[-1].condition()[0] # FIXME! + grp = session.storage.node(guid, ns.tagit.storage.Group) + grp.represented_by = browser.cursor + logger.info(f'{browser.cursor} now represents {grp}') + + +class RemoveFromGroup(Action): + """Remove the selected item from the group""" + text = kp.StringProperty('Remove from group') + + def ktrigger(self, evt): + return Binding.check(evt, self.cfg('bindings', 'grouping', 'remove')) + + def apply(self): + with self.root.browser as browser, \ + self.root.filter as filter, \ + self.root.session as session: + if len(filter.t_head) > 0 and \ + filter.t_head[-1].predicate() == 'group': + guid = filter.t_head[-1].condition()[0] + grp = session.storage.group(guid) + ents = self.root.session.storage.entities(browser.unfold(browser.selection)) + ents -= grp + + try: + rep = grp.represented_by + if grp not in rep.group: + # representative was removed, pick a random one + random.choice(list(rep.members())).represent(grp) # FIXME: member relation? + # grp.represented_by = random.choice(list(rep.members())) + + except ValueError: + # group is now empty + pass + + # set cursor to a non-selected (hence non-removed) item + # clear the selection + browser.cursor = browser.neighboring_unselected() + browser.selection = [browser.cursor] if browser.cursor is not None else [] + + # change event + session.dispatch('on_predicate_modified', 'group', items, {grp}) + + self.root.trigger('JumpToCursor') + + +## config ## + +config.declare(('ui', 'standalone', 'grouping', 'autoname'), config.Bool(), True, + __name__, 'Auto-name groups', 'If enabled, group names are auto-generated (resulting in somewhat cryptical names). If disabled, a name can be specified when creating new groups.') + +# keybindings + +config.declare(('bindings', 'grouping', 'create'), + config.Keybind(), Binding.simple('g', Binding.mCTRL, Binding.mREST), + __name__, CreateGroup.text.defaultvalue, CreateGroup.__doc__) + +config.declare(('bindings', 'grouping', 'ungroup'), + config.Keybind(), Binding.simple('g', [Binding.mALT, Binding.mCTRL], Binding.mREST), + __name__, DissolveGroup.text.defaultvalue, DissolveGroup.__doc__) + +config.declare(('bindings', 'grouping', 'add'), + config.Keybind(), Binding.simple('h', [Binding.mCTRL], Binding.mREST), + __name__, AddToGroup.text.defaultvalue, AddToGroup.__doc__) + +config.declare(('bindings', 'grouping', 'open'), + config.Keybind(), Binding.simple('g', None, Binding.mREST), + __name__, OpenGroup.text.defaultvalue, OpenGroup.__doc__) + +config.declare(('bindings', 'grouping', 'represent'), + config.Keybind(), Binding.simple('g', [Binding.mCTRL, Binding.mSHIFT], Binding.mREST), + __name__, RepresentGroup.text.defaultvalue, RepresentGroup.__doc__) + +config.declare(('bindings', 'grouping', 'remove'), + config.Keybind(), Binding.simple('h', [Binding.mSHIFT, Binding.mCTRL], Binding.mREST), + __name__, RemoveFromGroup.text.defaultvalue, RemoveFromGroup.__doc__) + +## EOF ## diff --git a/tagit/dialogues/__init__.py b/tagit/dialogues/__init__.py new file mode 100644 index 0000000..bee5bf4 --- /dev/null +++ b/tagit/dialogues/__init__.py @@ -0,0 +1,59 @@ +"""Popup dialogues. + +A dialogue can be opened from the main application. +It appears on top of the application and prevent its use until the dialogue +is closed. A dialogue contains buttons whose presses can be captured. + +>>> dlg = LabelDialogue(text='Hello world') +>>> dlg.bind(on_ok=...) +>>> dlg.bind(on_cancel=...) +>>> dlg.open() + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 +""" +# standard imports +import typing + +# inner-module imports +##from .spash import Splash +#from .autoinput import AutoTextInput +#from .console import Console +#from .dir_creator import DirCreator +#from .dir_picker import DirPicker +#from .error import Error +#from .export import Export +#from .file_creator import FileCreator +#from .file_picker import FilePicker +#from .message import Message +#from .numeric_input import NumericInput +#from .path_creator import PathCreator +#from .path_picker import PathPicker +#from .progress import Progress +#from .project import Project +#from .simple_input import SimpleInput +#from .stoken import TokenEdit +#from .yesno import YesNo + +# exports +__all__: typing.Sequence[str] = ( + #'Console', + #'DirCreator', + #'DirPicker', + #'Error', + #'Export', + #'FileCreator', + #'FilePicker', + #'Message', + #'NumericInput', + #'PathCreator', + #'PathPicker', + #'Progress', + #'Project', + #'SimpleInput', + #'TokenEdit', + #'YesNo', + ) + +## EOF ## diff --git a/tagit/external/__init__.py b/tagit/external/__init__.py new file mode 100644 index 0000000..b973c86 --- /dev/null +++ b/tagit/external/__init__.py @@ -0,0 +1,15 @@ +""" + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 +""" +# standard imports +import typing + +# constants + +# exports +__all__: typing.Sequence[str] = [] + +## EOF ## diff --git a/tagit/external/kivy_garden/__init__.py b/tagit/external/kivy_garden/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tagit/external/kivy_garden/contextmenu/__init__.py b/tagit/external/kivy_garden/contextmenu/__init__.py new file mode 100644 index 0000000..ac55bff --- /dev/null +++ b/tagit/external/kivy_garden/contextmenu/__init__.py @@ -0,0 +1,11 @@ +from .context_menu import ContextMenu, \ + AbstractMenu, \ + AbstractMenuItem, \ + AbstractMenuItemHoverable, \ + ContextMenuItem, \ + ContextMenuDivider, \ + ContextMenuText, \ + ContextMenuTextItem + +from .app_menu import AppMenu, \ + AppMenuTextItem \ No newline at end of file diff --git a/tagit/external/kivy_garden/contextmenu/_version.py b/tagit/external/kivy_garden/contextmenu/_version.py new file mode 100644 index 0000000..3ce5ddd --- /dev/null +++ b/tagit/external/kivy_garden/contextmenu/_version.py @@ -0,0 +1 @@ +__version__ = '0.1.0.dev1' diff --git a/tagit/external/kivy_garden/contextmenu/app_menu.kv b/tagit/external/kivy_garden/contextmenu/app_menu.kv new file mode 100644 index 0000000..644c6e5 --- /dev/null +++ b/tagit/external/kivy_garden/contextmenu/app_menu.kv @@ -0,0 +1,25 @@ +: + height: dp(30) + size_hint: 1, None + + canvas.before: + Color: + rgb: 0.2, 0.2, 0.2 + Rectangle: + pos: self.pos + size: self.size + + +: + disabled: True + size_hint: None, None + on_children: self._check_submenu() + font_size: '15sp' + background_normal: "" + background_down: "" + background_color: root.hl_color if self.state == 'down' else (0.2, 0.2, 0.2, 1.0) + background_disabled_normal: "" + background_disabled_down: "" + border: (0, 0, 0, 0) + size: self.texture_size[0], dp(30) + padding_x: dp(10) diff --git a/tagit/external/kivy_garden/contextmenu/app_menu.py b/tagit/external/kivy_garden/contextmenu/app_menu.py new file mode 100644 index 0000000..5394ec0 --- /dev/null +++ b/tagit/external/kivy_garden/contextmenu/app_menu.py @@ -0,0 +1,118 @@ +from kivy.uix.relativelayout import RelativeLayout +from kivy.uix.stacklayout import StackLayout +from kivy.uix.behaviors import ToggleButtonBehavior +from kivy.uix.togglebutton import ToggleButton +from kivy.lang import Builder +import kivy.properties as kp +import os + +from .context_menu import AbstractMenu, AbstractMenuItem, AbstractMenuItemHoverable, HIGHLIGHT_COLOR + + +class AppMenu(StackLayout, AbstractMenu): + bounding_box = kp.ObjectProperty(None) + + def __init__(self, *args, **kwargs): + super(AppMenu, self).__init__(*args, **kwargs) + self.hovered_menu_item = None + + def update_height(self): + max_height = 0 + for widget in self.menu_item_widgets: + if widget.height > max_height: + max_height = widget.height + return max_height + + def on_children(self, obj, new_children): + for w in new_children: + # bind events that update app menu height when any of its children resize + w.bind(on_size=self.update_height) + w.bind(on_height=self.update_height) + + def get_context_menu_root_parent(self): + return self + + def self_or_submenu_collide_with_point(self, x, y): + collide_widget = None + + # Iterate all siblings and all children + for widget in self.menu_item_widgets: + widget_pos = widget.to_window(0, 0) + if widget.collide_point(x - widget_pos[0], y - widget_pos[1]) and not widget.disabled: + if self.hovered_menu_item is None: + self.hovered_menu_item = widget + + if self.hovered_menu_item != widget: + self.hovered_menu_item = widget + for sibling in widget.siblings: + sibling.state = 'normal' + + if widget.state == 'normal': + widget.state = 'down' + widget.on_release() + + for sib in widget.siblings: + sib.hovered = False + elif widget.get_submenu() is not None and not widget.get_submenu().visible: + widget.state = 'normal' + + return collide_widget + + def close_all(self): + for submenu in [w.get_submenu() for w in self.menu_item_widgets if w.get_submenu() is not None]: + submenu.hide() + for w in self.menu_item_widgets: + w.state = 'normal' + + def hide_app_menus(self, obj, pos): + if not self.collide_point(pos.x, pos.y): + for w in [w for w in self.menu_item_widgets if not w.disabled and w.get_submenu().visible]: + submenu = w.get_submenu() + if submenu.self_or_submenu_collide_with_point(pos.x, pos.y) is None: + self.close_all() + self._cancel_hover_timer() + + +class AppMenuTextItem(ToggleButton, AbstractMenuItem): + label = kp.ObjectProperty(None) + text = kp.StringProperty('') + font_size = kp.NumericProperty(14) + color = kp.ListProperty([1, 1, 1, 1]) + hl_color = kp.ListProperty(HIGHLIGHT_COLOR) + + def on_release(self): + submenu = self.get_submenu() + + if self.state == 'down': + root = self._root_parent + submenu.bounding_box_widget = root.bounding_box if root.bounding_box else root.parent + + submenu.bind(visible=self.on_visible) + submenu.show(self.x, self.y - 1) + + for sibling in self.siblings: + if sibling.get_submenu() is not None: + sibling.state = 'normal' + sibling.get_submenu().hide() + + self.parent._setup_hover_timer() + else: + self.parent._cancel_hover_timer() + submenu.hide() + + def on_visible(self, *args): + submenu = self.get_submenu() + if self.width > submenu.get_max_width(): + submenu.width = self.width + + def _check_submenu(self): + super(AppMenuTextItem, self)._check_submenu() + self.disabled = (self.get_submenu() is None) + + # def on_mouse_down(self): + # print('on_mouse_down') + # return True + + +_path = os.path.dirname(os.path.realpath(__file__)) +Builder.load_file(os.path.join(_path, 'app_menu.kv')) diff --git a/tagit/external/kivy_garden/contextmenu/context_menu.kv b/tagit/external/kivy_garden/contextmenu/context_menu.kv new file mode 100644 index 0000000..c3f7133 --- /dev/null +++ b/tagit/external/kivy_garden/contextmenu/context_menu.kv @@ -0,0 +1,125 @@ +: + cols: 1 + size_hint: None, None + spacing: 0, 0 + spacer: _spacer + on_visible: self._on_visible(args[1]) + on_parent: self._on_visible(self.visible) + + Widget: + id: _spacer + size_hint: 1, None + height: dp(3) + canvas.before: + Color: + rgb: root.hl_color + Rectangle: + pos: self.pos + size: self.size + + +: + size_hint: None, None + submenu_arrow: _submenu_arrow + on_children: self._check_submenu() + on_parent: self._check_submenu() + canvas.before: + Color: + rgb: (0.15, 0.15, 0.15) + Rectangle: + pos: 0,0 + size: self.size + + Widget: + id: _submenu_arrow + size_hint: None, None + width: dp(6) + height: dp(11) + pos: self.parent.width - self.width - dp(5), (self.parent.height - self.height) / 2 + canvas.before: + Translate: + xy: self.pos + Color: + rgb: (0.35, 0.35, 0.35) if self.disabled else (1, 1, 1) + Triangle: + points: [0,0, self.width,self.height/2, 0,self.height] + Translate: + xy: (-self.pos[0], -self.pos[1]) + + +: + label: _label + width: self.parent.width if self.parent else 0 + height: dp(26) + font_size: '15sp' + + Label: + pos: 0,0 + id: _label + text: self.parent.text + color: self.parent.color + font_size: self.parent.font_size + padding: dp(10), 0 + halign: 'left' + valign: 'middle' + size: self.texture_size + size_hint: None, 1 + + +: + on_hovered: self._on_hovered(args[1]) + canvas.before: + Color: + rgb: (0.25, 0.25, 0.25) if self.hovered and not self.disabled else (0.15, 0.15, 0.15) + Rectangle: + pos: 0,0 + size: self.size + + +: + font_size: '10sp' + height: dp(20) if len(self.label.text) > 0 else dp(1) + canvas.before: + Color: + rgb: (0.25, 0.25, 0.25) + Rectangle: + pos: 0,self.height - 1 + size: self.width, 1 + + +: + size_hint: None, None + font_size: '12sp' + height: dp(20) + background_normal: "" + background_down: "" + background_color: HIGHLIGHT_COLOR + border: (0, 0, 0, 0) + on_press: self.background_color = 0.10, 0.6, 0.8, 1.0 + on_release: self.background_color = HIGHLIGHT_COLOR + + +: + size_hint: None, None + font_size: '12sp' + size: dp(30), dp(20) + background_normal: "" + background_down: "" + background_color: HIGHLIGHT_COLOR if self.state == 'down' else (0.25, 0.25, 0.25, 1.0) + border: (0, 0, 0, 0) + on_press: self.background_color = 0.10, 0.6, 0.8, 1.0 + on_release: self.background_color = HIGHLIGHT_COLOR + + +: + size: self.texture_size[0], dp(18) + size_hint: None, None + font_size: '12sp' + + +: + size_hint: None, None + height: dp(22) + font_size: '12sp' + padding: dp(7), dp(3) + multiline: False diff --git a/tagit/external/kivy_garden/contextmenu/context_menu.py b/tagit/external/kivy_garden/contextmenu/context_menu.py new file mode 100644 index 0000000..1613756 --- /dev/null +++ b/tagit/external/kivy_garden/contextmenu/context_menu.py @@ -0,0 +1,287 @@ +from kivy.uix.gridlayout import GridLayout +from kivy.uix.relativelayout import RelativeLayout +from kivy.uix.behaviors import ButtonBehavior +from kivy.lang import Builder +from kivy.clock import Clock +from functools import partial + +import kivy.properties as kp +import os + + +HIGHLIGHT_COLOR = [0.2, 0.71, 0.9, 1] + + +class AbstractMenu(object): + cancel_handler_widget = kp.ObjectProperty(None) + bounding_box_widget = kp.ObjectProperty(None) + + def __init__(self, *args, **kwargs): + self.clock_event = None + + def add_item(self, widget): + self.add_widget(widget) + + def add_text_item(self, text, on_release=None): + item = ContextMenuTextItem(text=text) + if on_release: + item.bind(on_release=on_release) + self.add_item(item) + + def get_height(self): + height = 0 + for widget in self.children: + height += widget.height + return height + + def hide_submenus(self): + for widget in self.menu_item_widgets: + widget.hovered = False + widget.hide_submenu() + + def self_or_submenu_collide_with_point(self, x, y): + raise NotImplementedError() + + def on_cancel_handler_widget(self, obj, widget): + self.cancel_handler_widget.bind(on_touch_down=self.hide_app_menus) + + def hide_app_menus(self, obj, pos): + raise NotImplementedError() + + @property + def menu_item_widgets(self): + """ + Return all children that are subclasses of ContextMenuItem + """ + return [w for w in self.children if issubclass(w.__class__, AbstractMenuItem)] + + def _setup_hover_timer(self): + if self.clock_event is None: + self.clock_event = Clock.schedule_interval(partial(self._check_mouse_hover), 0.05) + + def _check_mouse_hover(self, obj): + from kivy.core.window import Window + self.self_or_submenu_collide_with_point(*Window.mouse_pos) + + def _cancel_hover_timer(self): + if self.clock_event: + self.clock_event.cancel() + self.clock_event = None + + +class ContextMenu(GridLayout, AbstractMenu): + visible = kp.BooleanProperty(False) + spacer = kp.ObjectProperty(None) + hl_color = kp.ListProperty(HIGHLIGHT_COLOR) + + + def __init__(self, *args, **kwargs): + super(ContextMenu, self).__init__(*args, **kwargs) + self.orig_parent = None + # self._on_visible(False) + + def hide(self): + self.visible = False + + def show(self, x=None, y=None): + self.visible = True + self._add_to_parent() + self.hide_submenus() + + root_parent = self.bounding_box_widget if self.bounding_box_widget is not None else self.get_context_menu_root_parent() + if root_parent is None: + return + + point_relative_to_root = root_parent.to_local(*self.to_window(x, y)) + + # Choose the best position to open the menu + if x is not None and y is not None: + if point_relative_to_root[0] + self.width < root_parent.width: + pos_x = x + else: + pos_x = x - self.width + if issubclass(self.parent.__class__, AbstractMenuItem): + pos_x -= self.parent.width + + if point_relative_to_root[1] - self.height < 0: + pos_y = y + if issubclass(self.parent.__class__, AbstractMenuItem): + pos_y -= self.parent.height + self.spacer.height + else: + pos_y = y - self.height + + self.pos = pos_x, pos_y + + def self_or_submenu_collide_with_point(self, x, y): + queue = self.menu_item_widgets + collide_widget = None + + # Iterate all siblings and all children + while len(queue) > 0: + widget = queue.pop(0) + submenu = widget.get_submenu() + if submenu is not None and widget.hovered: + queue += submenu.menu_item_widgets + + widget_pos = widget.to_window(0, 0) + if widget.collide_point(x - widget_pos[0], y - widget_pos[1]) and not widget.disabled: + widget.hovered = True + + collide_widget = widget + for sib in widget.siblings: + sib.hovered = False + elif submenu and submenu.visible: + widget.hovered = True + else: + widget.hovered = False + + return collide_widget + + def _on_visible(self, new_visibility): + if new_visibility: + self.size = self.get_max_width(), self.get_height() + self._add_to_parent() + # @todo: Do we need to remove self from self.parent.__context_menus? Probably not. + + elif self.parent and not new_visibility: + self.orig_parent = self.parent + + ''' + We create a set that holds references to all context menus in the parent widget. + It's necessary to keep at least one reference to this context menu. Otherwise when + removed from parent it might get de-allocated by GC. + ''' + if not hasattr(self.parent, '_ContextMenu__context_menus'): + self.parent.__context_menus = set() + self.parent.__context_menus.add(self) + + self.parent.remove_widget(self) + self.hide_submenus() + self._cancel_hover_timer() + + def _add_to_parent(self): + if not self.parent: + self.orig_parent.add_widget(self) + self.orig_parent = None + + # Create the timer on the outer most menu object + if self._get_root_context_menu() == self: + self._setup_hover_timer() + + def get_max_width(self): + max_width = 0 + for widget in self.menu_item_widgets: + width = widget.content_width if widget.content_width is not None else widget.width + if width is not None and width > max_width: + max_width = width + + return max_width + + def get_context_menu_root_parent(self): + """ + Return the bounding box widget for positioning sub menus. By default it's root context menu's parent. + """ + if self.bounding_box_widget is not None: + return self.bounding_box_widget + root_context_menu = self._get_root_context_menu() + return root_context_menu.bounding_box_widget if root_context_menu.bounding_box_widget else root_context_menu.parent + + def _get_root_context_menu(self): + """ + Return the outer most context menu object + """ + root = self + while issubclass(root.parent.__class__, ContextMenuItem) \ + or issubclass(root.parent.__class__, ContextMenu): + root = root.parent + return root + + def hide_app_menus(self, obj, pos): + return self.self_or_submenu_collide_with_point(pos.x, pos.y) is None and self.hide() + + +class AbstractMenuItem(object): + submenu = kp.ObjectProperty(None) + + def get_submenu(self): + return self.submenu if self.submenu != "" else None + + def show_submenu(self, x=None, y=None): + if self.get_submenu(): + self.get_submenu().show(*self._root_parent.to_local(x, y)) + + def hide_submenu(self): + submenu = self.get_submenu() + if submenu: + submenu.visible = False + submenu.hide_submenus() + + def _check_submenu(self): + if self.parent is not None and len(self.children) > 0: + submenus = [w for w in self.children if issubclass(w.__class__, ContextMenu)] + if len(submenus) > 1: + raise Exception('Menu item (ContextMenuItem) can have maximum one submenu (ContextMenu)') + elif len(submenus) == 1: + self.submenu = submenus[0] + + @property + def siblings(self): + return [w for w in self.parent.children if issubclass(w.__class__, AbstractMenuItem) and w != self] + + @property + def content_width(self): + return None + + @property + def _root_parent(self): + return self.parent.get_context_menu_root_parent() + + +class ContextMenuItem(RelativeLayout, AbstractMenuItem): + submenu_arrow = kp.ObjectProperty(None) + + def _check_submenu(self): + super(ContextMenuItem, self)._check_submenu() + if self.get_submenu() is None: + self.submenu_arrow.opacity = 0 + else: + self.submenu_arrow.opacity = 1 + + +class AbstractMenuItemHoverable(object): + hovered = kp.BooleanProperty(False) + + def _on_hovered(self, new_hovered): + if new_hovered: + spacer_height = self.parent.spacer.height if self.parent.spacer else 0 + self.show_submenu(self.width, self.height + spacer_height) + else: + self.hide_submenu() + + +class ContextMenuText(ContextMenuItem): + label = kp.ObjectProperty(None) + submenu_postfix = kp.StringProperty(' ...') + text = kp.StringProperty('') + font_size = kp.NumericProperty(14) + color = kp.ListProperty([1,1,1,1]) + + def __init__(self, *args, **kwargs): + super(ContextMenuText, self).__init__(*args, **kwargs) + + @property + def content_width(self): + # keep little space for eventual arrow for submenus + return self.label.texture_size[0] + 10 + + +class ContextMenuDivider(ContextMenuText): + pass + + +class ContextMenuTextItem(ButtonBehavior, ContextMenuText, AbstractMenuItemHoverable): + pass + + +_path = os.path.dirname(os.path.realpath(__file__)) +Builder.load_file(os.path.join(_path, 'context_menu.kv')) diff --git a/tagit/external/setproperty/README.md b/tagit/external/setproperty/README.md new file mode 100644 index 0000000..e579132 --- /dev/null +++ b/tagit/external/setproperty/README.md @@ -0,0 +1,5 @@ + +build with + +$ python setup.py build_ext --inplace + diff --git a/tagit/external/setproperty/__init__.py b/tagit/external/setproperty/__init__.py new file mode 100644 index 0000000..b8fe9c2 --- /dev/null +++ b/tagit/external/setproperty/__init__.py @@ -0,0 +1,3 @@ + +from .setproperty import SetProperty + diff --git a/tagit/external/setproperty/setproperty.pxd b/tagit/external/setproperty/setproperty.pxd new file mode 100644 index 0000000..51acb25 --- /dev/null +++ b/tagit/external/setproperty/setproperty.pxd @@ -0,0 +1,9 @@ + +from kivy.properties cimport Property, PropertyStorage +from kivy._event cimport EventDispatcher, EventObservers + + + +cdef class SetProperty(Property): + pass + diff --git a/tagit/external/setproperty/setproperty.pyx b/tagit/external/setproperty/setproperty.pyx new file mode 100644 index 0000000..21bacbb --- /dev/null +++ b/tagit/external/setproperty/setproperty.pyx @@ -0,0 +1,125 @@ + +from weakref import ref + +cdef inline void observable_set_dispatch(object self) except *: + cdef Property prop = self.prop + obj = self.obj() + if obj is not None: + prop.dispatch(obj) + + +class ObservableSet(set): + # Internal class to observe changes inside a native python set. + def __init__(self, *largs): + self.prop = largs[0] + self.obj = ref(largs[1]) + super(ObservableSet, self).__init__(*largs[2:]) + + def __iand__(self, *largs): + set.__iand__(self, *largs) + observable_set_dispatch(self) + + def __ior__(self, *largs): + set.__ior__(self, *largs) + observable_set_dispatch(self) + + def __isub__(self, *largs): + set.__isub__(self, *largs) + observable_set_dispatch(self) + + def __ixor__(self, *largs): + set.__ixor__(self, *largs) + observable_set_dispatch(self) + + def add(self, *largs): + set.add(self, *largs) + observable_set_dispatch(self) + + def clear(self): + set.clear(self) + observable_set_dispatch(self) + + def difference_update(self, *largs): + set.difference_update(self, *largs) + observable_set_dispatch(self) + + def discard(self, *largs): + set.discard(self, *largs) + observable_set_dispatch(self) + + def intersection_update(self, *largs): + set.intersection_update(self, *largs) + observable_set_dispatch(self) + + def pop(self, *largs): + cdef object result = set.pop(self, *largs) + observable_set_dispatch(self) + return result + + def remove(self, *largs): + set.remove(self, *largs) + observable_set_dispatch(self) + + def symmetric_difference_update(self, *largs): + set.symmetric_difference_update(self, *largs) + observable_set_dispatch(self) + + def update(self, *largs): + set.update(self, *largs) + observable_set_dispatch(self) + + +cdef class SetProperty(Property): + '''Property that represents a set. + + :Parameters: + `defaultvalue`: set, defaults to set() + Specifies the default value of the property. + + .. warning:: + + When assigning a set to a :class:`SetProperty`, the set stored in + the property is a shallow copy of the set and not the original set. This can + be demonstrated with the following example:: + + >>> class MyWidget(Widget): + >>> my_set = SetProperty([]) + + >>> widget = MyWidget() + >>> my_set = {1, 5, {'hi': 'hello'}} + >>> widget.my_set = my_set + >>> print(my_set is widget.my_set) + False + >>> my_set.add(10) + >>> print(my_set, widget.my_set) + {1, 5, {'hi': 'hello'}, 10} {1, 5, {'hi': 'hello'}} + + However, changes to nested levels will affect the property as well, + since the property uses a shallow copy of my_set. + + ''' + def __init__(self, defaultvalue=0, **kw): + defaultvalue = set() if defaultvalue == 0 else defaultvalue + + super(SetProperty, self).__init__(defaultvalue, **kw) + + cpdef PropertyStorage link(self, EventDispatcher obj, str name): + Property.link(self, obj, name) + cdef PropertyStorage ps = obj.__storage[self._name] + if ps.value is not None: + ps.value = ObservableSet(self, obj, ps.value) + + cdef check(self, EventDispatcher obj, value, PropertyStorage property_storage): + if Property.check(self, obj, value, property_storage): + return True + if type(value) is not ObservableSet: + raise ValueError('%s.%s accept only ObservableSet' % ( + obj.__class__.__name__, + self.name)) + + cpdef set(self, EventDispatcher obj, value): + if value is not None: + value = ObservableSet(self, obj, value) + Property.set(self, obj, value) + + diff --git a/tagit/external/setproperty/setup.py b/tagit/external/setproperty/setup.py new file mode 100644 index 0000000..8500340 --- /dev/null +++ b/tagit/external/setproperty/setup.py @@ -0,0 +1,6 @@ +from distutils.core import Extension, setup +from Cython.Build import cythonize + +# define an extension that will be cythonized and compiled +ext = Extension(name="setproperty", sources=["setproperty.pyx"]) +setup(ext_modules=cythonize(ext)) diff --git a/tagit/external/setproperty/test.py b/tagit/external/setproperty/test.py new file mode 100644 index 0000000..e241786 --- /dev/null +++ b/tagit/external/setproperty/test.py @@ -0,0 +1,62 @@ +from kivy.app import App +from kivy.lang import Builder +from time import time +from kivy.uix.label import Label +from kivy.uix.boxlayout import BoxLayout +import kivy.properties as kp +from setproperty import SetProperty + +Builder.load_string(''' +: + orientation: 'vertical' + text: '' + + BoxLayout: + orientation: 'horizontal' + + ToggleButton: + id: btn_add + group: 'action' + text: 'add' + state: 'down' + + ToggleButton: + group: 'action' + text: 'delete' + + TextInput + id: value + + Button: + on_press: root.update_dict(btn_add.state == 'down', value.text) + text: 'change set' + + Label: + id: dictout + text: root.text + +''') + + +class Foo(BoxLayout): + + text = kp.StringProperty() + my_set = SetProperty() + + def on_my_set(self, wx, my_set): + self.text = str(time()) + ' ' + str(my_set) + + def update_dict(self, add, value): + if add: + self.my_set.add(value) + else: + self.my_set.discard(value) + + +class TestApp(App): + def build(self): + return Foo() + +if __name__ == '__main__': + TestApp().run() + diff --git a/tagit/external/tooltip.kv b/tagit/external/tooltip.kv new file mode 100644 index 0000000..27c3ab7 --- /dev/null +++ b/tagit/external/tooltip.kv @@ -0,0 +1,12 @@ + +: + size_hint: None, None + size: self.texture_size[0]+5, self.texture_size[1]+5 + canvas.before: + Color: + rgb: 0.2, 0.2, 0.2 + Rectangle: + size: self.size + pos: self.pos + +## EOF ## diff --git a/tagit/external/tooltip.py b/tagit/external/tooltip.py new file mode 100644 index 0000000..2865206 --- /dev/null +++ b/tagit/external/tooltip.py @@ -0,0 +1,67 @@ +"""Tooltips. + +From: + http://stackoverflow.com/questions/34468909/how-to-make-tooltip-using-kivy + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 + +""" +# standard imports +import os + +# kivy imports +from kivy.lang import Builder +from kivy.uix.label import Label +from kivy.clock import Clock +# Cannot import kivy.core.window.Window here; Leads to a segfault. +# Doing it within the *Tooltip* class works just fine, though. + +# exports +__all__ = ('Tooltip', ) + + +## CODE ## + +Builder.load_file(os.path.join(os.path.dirname(__file__), 'tooltip.kv')) + +class Tooltip_Label(Label): + pass + +# FIXME: Tooltip makes the whole UI *way* too slow, hence it's body is disabled +class Tooltip(object): + def set_tooltip(self, text): + pass + +# if hasattr(self, '_tooltip_wx') and self._tooltip_wx is not None: +# self._tooltip_wx.text = text +# else: +# self._tooltip_wx = Tooltip_Label(text=text) +# from kivy.core.window import Window +# Window.bind(mouse_pos=self.on_mouse_pos) +# +# def on_mouse_pos(self, *args): +# if not self.get_root_window(): +# return +# +# pos_x, pos_y = pos = args[1] +# from kivy.core.window import Window +# pos_x = max(0, min(pos_x, Window.width - self._tooltip_wx.width)) +# pos_y = max(0, min(pos_y, Window.height - self._tooltip_wx.height)) +# self._tooltip_wx.pos = (pos_x, pos_y) +# +# Clock.unschedule(self.display_tooltip) # cancel scheduled event since I moved the cursor +# self.close_tooltip() # close if it's opened +# if self.collide_point(*pos): +# Clock.schedule_once(self.display_tooltip, 1) +# +# def close_tooltip(self, *args): +# from kivy.core.window import Window +# Window.remove_widget(self._tooltip_wx) +# +# def display_tooltip(self, *args): +# from kivy.core.window import Window +# Window.add_widget(self._tooltip_wx) + +## EOF ## diff --git a/tagit/tiles/__init__.py b/tagit/tiles/__init__.py new file mode 100644 index 0000000..3ed53b9 --- /dev/null +++ b/tagit/tiles/__init__.py @@ -0,0 +1,61 @@ +""" + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 +""" +# standard imports +import typing + +# tagit imports +from tagit.utils.builder import BuilderBase + +# inner-module imports +##from .anomalies import Anomalies # FIXME: skeleton only +#from .browser_tags import BrowserTags +#from .buttons import Buttons +#from .cursor_tags import CursorTags +#from .entity_histogram import EntityHistogram +#from .geo import Map +#from .hints import Hints +#from .info import Info +#from .libsummary import LibSummary +#from .searchtree import Searchtree +#from .selection_tags import SelectionTags +#from .suggested_tags import SuggestedTags +#from .tag_distribution import TagDistribution +#from .tag_histogram import TagHistogram +#from .tag_tree import TagTree +#from .tagcloud import Tagcloud +#from .venn import Venn + +# exports +__all__: typing.Sequence[str] = ( + 'TileBuilder', + ) + + +## code ## + +class TileBuilder(BuilderBase): + _factories = { +# #'Anomalies': Anomalies, +# 'BrowserTags': BrowserTags, +# 'Buttons': Buttons, +# 'CursorTags': CursorTags, +# 'EntityHistogram': EntityHistogram, +# 'Geo': Map, +# 'Hints': Hints, +# 'Info': Info, +# 'LibSummary': LibSummary, +# 'Searchtree': Searchtree, +# 'SelectionTags': SelectionTags, +# 'SuggestedTags': SuggestedTags, +# 'TagDistribution': TagDistribution, +# 'TagHistogram': TagHistogram, +# 'TagTree': TagTree, +# 'Tagcloud': Tagcloud, +# 'Venn': Venn, + } + +## EOF ## diff --git a/tagit/tiles/decoration.kv b/tagit/tiles/decoration.kv new file mode 100644 index 0000000..a53d013 --- /dev/null +++ b/tagit/tiles/decoration.kv @@ -0,0 +1,58 @@ + +# NOTE: +# TileDecoration assumes as *cbox* property that identifies the widget +# to which the main content will be added. + +: + cbox: cbox + + RelativeLayout: + id: cbox + +: + cbox: cbox + + canvas.after: + # tile shadow + Color: + rgb: 0.2,0.2,0.2 + Line: + rectangle: self.x+5,self.y+5,self.width-10,self.height-10 + width: 2 + + Color: + rgb: 0.6,0.6,0.6 + Line: + rectangle: self.x+7,self.y+7,self.width-14,self.height-14 + width: 1 + + RelativeLayout: + id: cbox + pos: 15, 15 + size: root.width-30, root.height-30 + size_hint: None, None + +: + cbox: cbox + + Label: + text: root.client.title + size: root.width, 20 + size_hint: None, None + pos: 0, root.height - self.height - 5 + + RelativeLayout: + id: cbox + pos: 5, 5 + size: root.width-10, root.height-30 + size_hint: None, None + + canvas.before: + Color: + rgba: 1,0,0,0.5 + Rectangle: + pos: 0, 0 + size: self.size + + +## EOF ## diff --git a/tagit/tiles/decoration.py b/tagit/tiles/decoration.py new file mode 100644 index 0000000..471058d --- /dev/null +++ b/tagit/tiles/decoration.py @@ -0,0 +1,68 @@ +""" + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 +""" +# standard imports +import os +import typing + +# kivy imports +from kivy.lang import Builder +from kivy.uix.relativelayout import RelativeLayout +import kivy.properties as kp + +# exports +__all__: typing.Sequence[str] = ( + 'TileDecorationBorder', + 'TileDecorationFilledRectangle', + 'TileDecorationVanilla', + ) + + +## code ## + +# load kv +Builder.load_file(os.path.join(os.path.dirname(__file__), 'decoration.kv')) + +# classes +class TileDecoration(RelativeLayout): + + cbox = kp.ObjectProperty(None) + client = kp.ObjectProperty(None) + + def __repr__(self): + return f'{self.__class__.__name__}({self.client})' + + def on_cbox(self, wx, cbox): + if cbox is not None and len(cbox.children) == 0: + cbox.add_widget(self.client) + + @property + def default_size(self): + return self.client.default_size + + +class TileDecorationVanilla(TileDecoration): + pass + + +class TileDecorationFilledRectangle(TileDecoration): + @property + def default_size(self): + width, height = self.client.default_size + width = None if width is None else width + 10 + height = None if height is None else height + 30 + return width, height + + +class TileDecorationBorder(TileDecoration): + @property + def default_size(self): + width, height = self.client.default_size + width = None if width is None else width + 30 + height = None if height is None else height + 30 + return width, height + +## EOF ## diff --git a/tagit/utils/__init__.py b/tagit/utils/__init__.py index d143034..3f09078 100644 --- a/tagit/utils/__init__.py +++ b/tagit/utils/__init__.py @@ -9,6 +9,8 @@ import typing # inner-module imports from . import bsfs +from . import time as ttime +from .frame import Frame from .shared import * # FIXME: port properly # exports diff --git a/tagit/utils/builder.py b/tagit/utils/builder.py new file mode 100644 index 0000000..f6c5818 --- /dev/null +++ b/tagit/utils/builder.py @@ -0,0 +1,82 @@ +""" + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 +""" +# standard imports +from collections import abc +from functools import partial +from inspect import isclass +import typing + +# exports +__all__: typing.Sequence[str] = ( + 'BuilderBase', + 'InvalidFactoryName', + ) + + +## code ## + +class InvalidFactoryName(KeyError): pass + +class BuilderBase(abc.Mapping, abc.Hashable): + _factories = dict() + + def __getitem__(self, key): + return self.get(key) + + def __contains__(self, key): + return key in self._factories + + def __iter__(self): + return iter(self._factories.keys()) + + def __hash__(self): + return hash(frozenset(self._factories.items())) + + def __len__(self): + return len(self._factories) + + def __eq__(self, other): + return type(self) == type(other) and self._factories == other._factories + + + def get(self, key): + if key not in self._factories: + raise InvalidFactoryName(key) + return self._factories[key] + + @classmethod + def keys(self): + return self._factories.keys() + + @classmethod + def items(self): + return self._factories.items() + + @classmethod + def describe(cls, key): + if key not in cls._factories: + raise InvalidFactoryName(key) + desc = cls._factories[key].__doc__ + return desc if desc is not None else '' + + def prepare(self, key, *args, **kwargs): + # If building is to be customized, overwrite this function. + return partial(self[key], *args, **kwargs) + + def build(self, key, *args, **kwargs): + fu = self.prepare(key, *args, **kwargs) + return fu() + + def key_from_instance(self, cls): + for key, clbk in self._factories.items(): + if isclass(clbk) and isinstance(cls, clbk): + return key + if not isclass(clbk) and cls == clbk: + return key + raise KeyError(type(cls)) + +## EOF ## diff --git a/tagit/utils/frame.py b/tagit/utils/frame.py new file mode 100644 index 0000000..c6bdc1e --- /dev/null +++ b/tagit/utils/frame.py @@ -0,0 +1,84 @@ +""" + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 +""" +# standard imports +import json + +# exports +__all__ = ('Frame', ) + + +## code ## + +class Frame(dict): + def __init__(self, cursor=None, selection=None, offset=0, **kwargs): + super(Frame, self).__init__(**kwargs) + selection = selection if selection is not None else [] + self['cursor'] = cursor + self['selection'] = selection + self['offset'] = offset + + @property + def selection(self): + return self['selection'] + + @property + def cursor(self): + return self['cursor'] + + @property + def offset(self): + return self['offset'] + + def copy(self): + return Frame(**super(Frame, self).copy()) + + def serialize(self): + return json.dumps({ + 'cursor': self.cursor.guid if self.cursor is not None else 'None', + 'group': self.cursor.group if hasattr(self.cursor, 'group') else 'None', + 'selection': [img.guid for img in self.selection], + 'offset': self.offset + }) + + @staticmethod + def from_serialized(lib, serialized, ignore_errors=True): + d = json.loads(serialized) + + # load cursor + cursor = None + try: + if 'cursor' in d and d['cursor'] is not None and d['cursor'].lower() != 'none': + cursor = lib.entity(d['cursor']) + except KeyError as err: + if not ignore_errors: + raise err + + if 'group' in d and d['group'] is not None and d['group'].lower() != 'none': + try: + # FIXME: late import; breaks module dependency structure + from tagit.storage.library.entity import Representative + cursor = Representative.Representative(lib, d['group']) + except ValueError: + # group doesn't exist anymore; ignore + pass + + # load selection + selection = [] + for guid in d.get('selection', []): + try: + selection.append(lib.entity(guid)) + except KeyError as err: + if not ignore_errors: + raise err + + return Frame( + cursor = cursor, + selection = selection, + offset = d.get('offset', 0) + ) + +## EOF ## diff --git a/tagit/utils/shared.py b/tagit/utils/shared.py index 13ffd2a..82fe672 100644 --- a/tagit/utils/shared.py +++ b/tagit/utils/shared.py @@ -6,22 +6,25 @@ A copy of the license is provided with the project. Author: Matthias Baumgartner, 2022 """ # standard imports +from collections import namedtuple import logging +import os import pkgutil import re import typing -# exports -__all__ = ('import_all', ) - - -## code ## - # exports __all__: typing.Sequence[str] = ( + 'Resolution', + 'Struct', + 'clamp', + 'flatten', 'fst', - 'is_list', 'import_all', + 'is_hex', + 'is_list', + 'magnitude_fmt', + 'truncate_dir', ) @@ -60,4 +63,64 @@ def import_all(module, exclude=None, verbose=False): return imports +def clamp(value, hi, lo=0): + """Restrain a *value* to the range *lo* to *hi*.""" + return max(lo, min(hi, value)) + +Resolution = namedtuple('resolution', ('width', 'height')) + +def truncate_dir(path, cutoff=3): + """Remove path up to last *cutoff* directories""" + if cutoff < 0: raise ValueError('path cutoff must be positive') + dirs = os.path.dirname(path).split(os.path.sep) + last_dirs = dirs[max(0, len(dirs) - cutoff):] + prefix = '' + if os.path.isabs(path) and len(last_dirs) == len(dirs): + prefix = os.path.sep + + return prefix + os.path.join(*(last_dirs + [os.path.basename(path)])) + +def magnitude_fmt(num, suffix='iB', scale=1024): + """Human-readable number format. + + adapted from Sridhar Ratnakumar, 2009 + https://stackoverflow.com/questions/1094841/reusable-library-to-get-human-readable-version-of-file-size + """ + for unit in ['','K','M','G','T','P','E','Z']: + if abs(num) < scale: + return "%3.1f%s%s" % (num, unit, suffix) + num /= scale + return "%.1f%s%s" % (num, 'Y', suffix) + +class Struct(dict): + """Dict with item access as members. + + >>> tup = Struct(timestamp=123, series=['1','2','3']) + >>> tup.timestamp + 123 + >>> tup['timestamp'] + 123 + + """ + def __getattr__(self, name): + return self[name] + def __setattr__(self, name, value): + self[name] = value + +def flatten(lst): + flat = [] + for itm in lst: + flat.extend(list(itm)) + return flat + +def is_hex(string): + """Return True if the *string* can be interpreted as a hex value.""" + try: + int(string, 16) + return True + except ValueError: + return False + except TypeError: + return False + ## EOF ## diff --git a/tagit/utils/time.py b/tagit/utils/time.py new file mode 100644 index 0000000..4260ac7 --- /dev/null +++ b/tagit/utils/time.py @@ -0,0 +1,63 @@ +"""Time helpers. + +* Camera local +* System local +* UTC + +Timestamp to datetime + * Timestamp + * in UTC + * Timezone + * Implicit system local timezone + * No known timezone + * Known timezone + +Datetime to timestamp + * always store as local time + * optionally with UTC offset + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 +""" +# standard imports +from datetime import timezone, datetime, tzinfo, timedelta + +# exports +__all__ = ('timestamp', 'from_timestamp') + + +## code ## + +timestamp_loc = lambda dt: dt.replace(tzinfo=timezone.utc).timestamp() + +timestamp_utc = lambda dt: dt.timestamp() + +from_timestamp_loc = lambda ts: datetime.utcfromtimestamp(ts) + +from_timestamp_utc = lambda ts: datetime.fromtimestamp(ts) + +now = datetime.now + +timestamp_min = timestamp_loc(datetime.min) + +timestamp_max = timestamp_loc(datetime.max) + +def utcoffset(dt): + if dt.tzinfo is None: + return local_tzo(dt) + elif dt.tzinfo is NoTimeZone: + return None + else: + return dt.tzinfo.utcoffset(dt).total_seconds() / 3600 + +NoTimeZone = timezone(timedelta(0), 'NoTimeZone') + +def local_tzo(dt=None): + """Return the offset between the local time and UTC. + (i.e. return the x of UTC+x). + """ + dt = datetime.now() if dt is None else dt + return (timestamp_loc(dt) - dt.timestamp()) / 3600 + +## EOF ## diff --git a/tagit/widgets/__init__.py b/tagit/widgets/__init__.py index c3ec3c0..3892a22 100644 --- a/tagit/widgets/__init__.py +++ b/tagit/widgets/__init__.py @@ -5,6 +5,7 @@ A copy of the license is provided with the project. Author: Matthias Baumgartner, 2022 """ # inner-module imports +from .bindings import Binding from .desktop import MainWindow ## EOF ## diff --git a/tagit/widgets/bindings.py b/tagit/widgets/bindings.py new file mode 100644 index 0000000..3192c4e --- /dev/null +++ b/tagit/widgets/bindings.py @@ -0,0 +1,278 @@ +"""Configurable keybindings. + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 +""" +# standard imports +import typing + +# tagit imports +from tagit.utils import errors + +# exports +__all__: typing.Sequence[str] = ( + 'Binding', + ) + + +## code ## + +class Binding(object): + """Handle keybindings. + + A keybinding is a set of three constraints: + * Key code + * Inclusive modifiers + * Exclusive modifiers + + Inclusive modifiers must be present, exclusive ones must not be present. + Modifiers occuring in neither of the two lists are ignored. + + Modifiers are always lowercase strings. Additionally to SHIFT, CTRL and ALT, + the modifiers "all" and "rest" can be used. + "all" is a shortcut for all of the modifiers known. + "rest" means all modifiers not consumed by the other list yet. "rest" can + therefore only occur in at most one of the lists. + + Usage example: + + >>> # From settings, with PGUP w/o modifiers as default + >>> Binding.check(evt, self.cfg("bindings", "browser", "page_prev", + ... default=Binding.simple(Binding.PGUP, None, Binding.mALL))) + + >>> # ESC or CTRL + SHIFT + a + >>> Binding.check(evt, Binding.multi((Binding.ESC, ), + ... (97, (Binding.mCTRL, Binding.mSHIFT), Binding.mREST)))) + + """ + + # Modifiers + mSHIFT = 'shift' + mCTRL = 'ctrl' + mALT = 'alt' + mCMD = 'cmd' + mALTGR = 'altgr' + mNUMLOCK = 'numlock' + mCAPSLOCK = 'capslock' + # Modifier specials + mALL = 'all' + mREST = 'rest' + # Special keys + BACKSPACE = 8 + TAB = 9 + ENTER = 13 + ESC = 27 + SPACEBAR = 32 + DEL = 127 + UP = 273 + DOWN = 274 + RIGHT = 275 + LEFT = 276 + INSERT = 277 + HOME = 278 + END = 279 + PGUP = 280 + PGDN = 281 + F1 = 282 + F2 = 283 + F3 = 284 + F4 = 285 + F5 = 286 + F6 = 287 + F7 = 288 + F8 = 289 + F9 = 290 + F10 = 291 + F11 = 292 + F12 = 293 + CAPSLOCK = 301 + RIGHT_SHIFT = 303 + LEFT_SHIFT = 304 + LEFT_CTRL = 305 + RIGHT_CTRL = 306 + ALTGR = 307 + ALT = 308 + CMD = 309 + + @staticmethod + def simple(code, inclusive=None, exclusive=None): + """Create a binding constraint.""" + # handle strings + inclusive = (inclusive, ) if isinstance(inclusive, str) else inclusive + exclusive = (exclusive, ) if isinstance(exclusive, str) else exclusive + + # handle None, ensure tuple + inclusive = tuple(inclusive) if inclusive is not None else tuple() + exclusive = tuple(exclusive) if exclusive is not None else tuple() + + # handle code + code = Binding.str_to_key(code.lower()) if isinstance(code, str) else code + if code is None: + raise errors.ProgrammingError('invalid key code') + + # build constraint + return [(code, inclusive, exclusive)] + + @staticmethod + def multi(*args): + """Return binding for multiple constraints.""" + return [Binding.simple(*arg)[0] for arg in args] + + @staticmethod + def from_string(string): + mods = (Binding.mSHIFT, Binding.mCTRL, Binding.mALT, Binding.mCMD, + Binding.mALTGR, Binding.mNUMLOCK, Binding.mCAPSLOCK) + + bindings = [] + for kcombo in (itm.strip() for itm in string.split(';')): + strokes = [key.lower().strip() for key in kcombo.split('+')] + + # modifiers; ignore lock modifiers + inc = [key for key in strokes if key in mods] + inc = [key for key in inc if key not in (Binding.mNUMLOCK, Binding.mCAPSLOCK)] + # key + code = [key for key in strokes if key not in mods] + if len(code) != 1: + raise errors.ProgrammingError('there must be exactly one key code in a keybinding') + code = Binding.str_to_key(code[0]) + if code is None: + raise errors.ProgrammingError('invalid key code') + + bindings.append((code, tuple(inc), (Binding.mREST, ))) + + return bindings + + @staticmethod + def to_string(constraints): + values = [] + for code, inc, exc in constraints: + values.append( + ' + '.join([m.upper() for m in inc] + [Binding.key_to_str(code)])) + return '; '.join(values) + + @staticmethod + def check(stroke, constraint): + """Return True if *evt* matches the *constraint*.""" + code, char, modifiers = stroke + all_ = {Binding.mSHIFT, Binding.mCTRL, Binding.mALT, Binding.mCMD, Binding.mALTGR} + for key, inclusive, exclusive in constraint: + inclusive, exclusive = set(inclusive), set(exclusive) + + if key in (code, char): # Otherwise, we don't have to process the modifiers + # Handle specials + if 'all' in inclusive: + inclusive = all_ + if 'all' in exclusive: + exclusive = all_ + if 'rest' in inclusive: + inclusive = all_ - exclusive + if 'rest' in exclusive: + exclusive = all_ - inclusive + + if (all([mod in modifiers for mod in inclusive]) and + all([mod not in modifiers for mod in exclusive])): + # Code and modifiers match + return True + + # No matching constraint found + return False + + @staticmethod + def key_to_str(code, default='?'): + if isinstance(code, str): + return code + + if 32 <= code and code <= 226 and code != 127: + return chr(code) + + return { + Binding.BACKSPACE : 'BACKSPACE', + Binding.TAB : 'TAB', + Binding.ENTER : 'ENTER', + Binding.ESC : 'ESC', + Binding.SPACEBAR : 'SPACEBAR', + Binding.DEL : 'DEL', + Binding.UP : 'UP', + Binding.DOWN : 'DOWN', + Binding.RIGHT : 'RIGHT', + Binding.LEFT : 'LEFT', + Binding.INSERT : 'INSERT', + Binding.HOME : 'HOME', + Binding.END : 'END', + Binding.PGUP : 'PGUP', + Binding.PGDN : 'PGDN', + Binding.F1 : 'F1', + Binding.F2 : 'F2', + Binding.F3 : 'F3', + Binding.F4 : 'F4', + Binding.F5 : 'F5', + Binding.F6 : 'F6', + Binding.F7 : 'F7', + Binding.F8 : 'F8', + Binding.F9 : 'F9', + Binding.F10 : 'F10', + Binding.F11 : 'F11', + Binding.F12 : 'F12', + Binding.CAPSLOCK : 'CAPSLOCK', + Binding.RIGHT_SHIFT : 'RIGHT_SHIFT', + Binding.LEFT_SHIFT : 'LEFT_SHIFT', + Binding.LEFT_CTRL : 'LEFT_CTRL', + Binding.RIGHT_CTRL : 'RIGHT_CTRL', + Binding.ALTGR : 'ALTGR', + Binding.ALT : 'ALT', + Binding.CMD : 'CMD', + }.get(code, default) + + @staticmethod + def str_to_key(char, default=None): + if isinstance(char, int): + return char + + try: + # check if ascii + code = ord(char) + if 32 <= code and code <= 226: + return code + except TypeError: + pass + + return { + 'BACKSPACE' : Binding.BACKSPACE, + 'TAB' : Binding.TAB, + 'ENTER' : Binding.ENTER, + 'ESC' : Binding.ESC, + 'SPACEBAR' : Binding.SPACEBAR, + 'DEL' : Binding.DEL, + 'UP' : Binding.UP, + 'DOWN' : Binding.DOWN, + 'RIGHT' : Binding.RIGHT, + 'LEFT' : Binding.LEFT, + 'INSERT' : Binding.INSERT, + 'HOME' : Binding.HOME, + 'END' : Binding.END, + 'PGUP' : Binding.PGUP, + 'PGDN' : Binding.PGDN, + 'F1' : Binding.F1, + 'F2' : Binding.F2, + 'F3' : Binding.F3, + 'F4' : Binding.F4, + 'F5' : Binding.F5, + 'F6' : Binding.F6, + 'F7' : Binding.F7, + 'F8' : Binding.F8, + 'F9' : Binding.F9, + 'F10' : Binding.F10, + 'F11' : Binding.F11, + 'F12' : Binding.F12, + 'CAPSLOCK' : Binding.CAPSLOCK, + 'RIGHT_SHIFT' : Binding.RIGHT_SHIFT, + 'LEFT_SHIFT' : Binding.LEFT_SHIFT, + 'LEFT_CTRL' : Binding.LEFT_CTRL, + 'RIGHT_CTRL' : Binding.RIGHT_CTRL, + 'ALTGR' : Binding.ALTGR, + 'ALT' : Binding.ALT, + 'CMD' : Binding.CMD, + }.get(char, default) + +## EOF ## diff --git a/tagit/widgets/browser.kv b/tagit/widgets/browser.kv new file mode 100644 index 0000000..ed40a44 --- /dev/null +++ b/tagit/widgets/browser.kv @@ -0,0 +1,100 @@ +#:import OpenGroup tagit.actions.grouping + +: + 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/actions/__init__.py | 2 +- tagit/actions/filter.py | 22 ++++++++++----------- tagit/apps/desktop.py | 7 ++++++- tagit/config/settings.json | 48 ---------------------------------------------- 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 + 10 files changed, 24 insertions(+), 63 deletions(-) (limited to 'tagit') diff --git a/tagit/actions/__init__.py b/tagit/actions/__init__.py index 24524b4..444bd73 100644 --- a/tagit/actions/__init__.py +++ b/tagit/actions/__init__.py @@ -18,7 +18,7 @@ from . import grouping #from . import misc #from . import objects #from . import planes -#from . import search +from . import search #from . import session #from . import tabs diff --git a/tagit/actions/filter.py b/tagit/actions/filter.py index 3702879..869844e 100644 --- a/tagit/actions/filter.py +++ b/tagit/actions/filter.py @@ -22,7 +22,7 @@ from tagit.widgets.bindings import Binding from tagit.widgets.filter import FilterAwareMixin # inner-module imports -from .action import Action +from . import action # exports __all__ = [] @@ -35,7 +35,7 @@ Builder.load_file(os.path.join(os.path.dirname(__file__), 'filter.kv')) # classes -class SearchByAddressOnce(Action): +class SearchByAddressOnce(action.Action): """Open the filters in address mode for a single edit""" text = kp.StringProperty('Inline edit') @@ -46,7 +46,7 @@ class SearchByAddressOnce(Action): self.root.filter.show_address_once() -class SetToken(Action): +class SetToken(action.Action): """Set all filters from a text query.""" text = kp.StringProperty('Set tokens') @@ -84,7 +84,7 @@ class SetToken(Action): dialogues.Error(text=f'syntax error: {e}').open() -class AddToken(Action): +class AddToken(action.Action): """Show a dialogue for adding a filter.""" text = kp.StringProperty('Add filter') @@ -123,7 +123,7 @@ class AddToken(Action): self.root.browser.frame = Frame() -class EditToken(Action): +class EditToken(action.Action): """Show a dialogue for editing a filter.""" text = kp.StringProperty('Edit token') @@ -182,7 +182,7 @@ class EditToken(Action): filter.f_tail.pop(idx) -class RemoveToken(Action): +class RemoveToken(action.Action): """Remove a filter.""" text = kp.StringProperty('Remove token') @@ -204,7 +204,7 @@ class RemoveToken(Action): filter.t_tail.remove(token) -class GoBack(Action): +class GoBack(action.Action): """Remove the rightmost filter from the search.""" text = kp.StringProperty('Previous search') @@ -222,7 +222,7 @@ class GoBack(Action): self.root.browser.frame = filter.f_head.pop(-1) -class GoForth(Action): +class GoForth(action.Action): """Add the rightmost filter to the search""" text = kp.StringProperty('Next search') @@ -240,7 +240,7 @@ class GoForth(Action): self.root.browser.frame = filter.f_tail.pop(0) -class JumpToToken(Action): +class JumpToToken(action.Action): """Jump to a filter token.""" text = kp.StringProperty('Jump to token') @@ -254,12 +254,12 @@ class JumpToToken(Action): self.root.trigger('GoForth', filter.t_tail.index(token) + 1) -class SearchmodeSwitch(Action, FilterAwareMixin): +class SearchmodeSwitch(action.Action, FilterAwareMixin): """Switch between shingle and address search bar display.""" text = kp.StringProperty('Toggle searchbar mode') def on_root(self, wx, root): - Action.on_root(self, wx, root) + action.Action.on_root(self, wx, root) FilterAwareMixin.on_root(self, wx, root) def on_searchmode(self, filter, searchmode): diff --git a/tagit/apps/desktop.py b/tagit/apps/desktop.py index 086a503..21a610c 100644 --- a/tagit/apps/desktop.py +++ b/tagit/apps/desktop.py @@ -32,8 +32,13 @@ class TagitApp(App): # set title self.title = 'tagit v2.0' + # FIXME: mb/port + # load essentials + from tagit.config.loader import load_settings, TAGITRC + cfg = load_settings(TAGITRC, 0) + # create widget - return desktop.MainWindow() + return desktop.MainWindow(cfg, None, None) # FIXME: expects cfg, stor, log arguments def on_start(self): # trigger startup operations diff --git a/tagit/config/settings.json b/tagit/config/settings.json index e0bb3cf..8fd4754 100644 --- a/tagit/config/settings.json +++ b/tagit/config/settings.json @@ -2,67 +2,19 @@ "ui": { "standalone": { "keytriggers": [ - "ClipboardCopy", - "ClipboardPaste", - "CreateGroup", - "DissolveGroup", - "AddToGroup", - "MoveCursorUp", - "MoveCursorDown", - "MoveCursorLeft", - "MoveCursorRight", - "MoveCursorLast", - "MoveCursorFirst", - "NextPage", - "PreviousPage", - "ScrollDown", - "ScrollUp", - "ZoomIn", - "ZoomOut", - "Select", - "SelectAll", - "SelectNone", - "SelectMulti", - "SelectRange", - "AddToken", - "GoBack", - "GoForth", - "SearchByAddressOnce", - "AddTag", - "EditTag", - "OpenGroup", - "RepresentGroup", - "Search", - "ShowSelected", - "RemoveSelected", - "OpenExternal", - "ShowHelp" ], "buttondocks": { "filter": [ - "AddToken", - "GoBack", - "GoForth" ], "navigation_left": [ - "MoveCursorFirst", - "PreviousPage", - "ScrollUp" ], "navigation_right": [ - "ScrollDown", - "NextPage", - "MoveCursorLast" ], "status": [ - "RotateLeft", - "DeleteObject", - "RotateRight" ] }, "context": { "root": [ - "CloseSessionAndExit" ] } } 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 1a8d8f8a37e78f48da88dd69e785234d822425ed Mon Sep 17 00:00:00 2001 From: Matthias Baumgartner Date: Fri, 6 Jan 2023 22:55:36 +0100 Subject: load from config, switch to browsing --- tagit/actions/__init__.py | 16 ++-- tagit/actions/misc.kv | 35 +++++++++ tagit/actions/misc.py | 167 ++++++++++++++++++++++++++++++++++++++++ tagit/actions/planes.kv | 15 ++++ tagit/actions/planes.py | 57 ++++++++++++++ tagit/config/loader.py | 4 +- tagit/config/port-config.yaml | 114 +++++++++++++++++++++++++++ tagit/config/settings.py | 12 ++- tagit/config/user-defaults.yaml | 112 +++++++++++++++++++++++++++ 9 files changed, 519 insertions(+), 13 deletions(-) create mode 100644 tagit/actions/misc.kv create mode 100644 tagit/actions/misc.py create mode 100644 tagit/actions/planes.kv create mode 100644 tagit/actions/planes.py create mode 100644 tagit/config/port-config.yaml create mode 100644 tagit/config/user-defaults.yaml (limited to 'tagit') diff --git a/tagit/actions/__init__.py b/tagit/actions/__init__.py index 444bd73..9fd6342 100644 --- a/tagit/actions/__init__.py +++ b/tagit/actions/__init__.py @@ -15,9 +15,9 @@ from tagit.utils.builder import BuilderBase from . import filter from . import grouping #from . import library -#from . import misc +from . import misc #from . import objects -#from . import planes +from . import planes from . import search #from . import session #from . import tabs @@ -88,9 +88,9 @@ class ActionBuilder(BuilderBase): #'ShellDrop': misc.ShellDrop, #'OpenExternal': misc.OpenExternal, #'Menu': misc.Menu, - #'ShowConsole': misc.ShowConsole, - #'ShowHelp': misc.ShowHelp, - #'ShowSettings': misc.ShowSettings, + 'ShowConsole': misc.ShowConsole, + 'ShowHelp': misc.ShowHelp, + 'ShowSettings': misc.ShowSettings, #'ClipboardCopy': misc.ClipboardCopy, #'ClipboardPaste': misc.ClipboardPaste, ## objects @@ -105,9 +105,9 @@ class ActionBuilder(BuilderBase): #'SetRank4': objects.SetRank4, #'SetRank5': objects.SetRank5, ## planes - #'ShowDashboard': planes.ShowDashboard, - #'ShowBrowsing': planes.ShowBrowsing, - #'ShowCodash': planes.ShowCodash, + 'ShowDashboard': planes.ShowDashboard, + 'ShowBrowsing': planes.ShowBrowsing, + 'ShowCodash': planes.ShowCodash, ## search #'Search': search.Search, #'ShowSelected': search.ShowSelected, diff --git a/tagit/actions/misc.kv b/tagit/actions/misc.kv new file mode 100644 index 0000000..f9d5157 --- /dev/null +++ b/tagit/actions/misc.kv @@ -0,0 +1,35 @@ +#:import resource_find kivy.resources.resource_find + +: + source: resource_find('atlas://misc/menu') + tooltip: 'Open the menu' + +: + source: resource_find('atlas://misc/shell') + tooltip: 'Open a terminal shell' + +: + source: resource_find('atlas://misc/open_external') + tooltip: 'Open selected items in an external application' + +: + source: resource_find('atlas://misc/console') + tooltip: 'Open the log console' + +: + source: resource_find('atlas://misc/help') + tooltip: 'Open the help' + +: + source: resource_find('atlas://misc/settings') + tooltip: 'Open the settings menu' + +: + source: resource_find('atlas://misc/clip_copy') + tooltip: 'Copy selected items to the clipboard' + +: + source: resource_find('atlas://misc/clip_paste') + tooltip: 'Import files from the clipboard' + +## EOF ## diff --git a/tagit/actions/misc.py b/tagit/actions/misc.py new file mode 100644 index 0000000..dc939ca --- /dev/null +++ b/tagit/actions/misc.py @@ -0,0 +1,167 @@ +""" + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 +""" +# standard imports +import logging +import os + +# kivy imports +from kivy.core.clipboard import Clipboard +from kivy.lang import Builder +import kivy.properties as kp +import webbrowser + +# tagit imports +from tagit import config +#from tagit.io_.sync import export # FIXME: mb/port +#from tagit.utils import fileopen # FIXME: mb/port +from tagit.widgets import Binding + +# inner-module imports +from .action import Action + +# constants +HELP_URL = 'https://www.igsor.net/projects/tagit/' + +# exports +__all__ = [] + + +## code ## + +logger = logging.getLogger(__name__) + +# load kv +Builder.load_file(os.path.join(os.path.dirname(__file__), 'misc.kv')) + +# classes +class Menu(Action): + """Open the menu.""" + text = kp.StringProperty('Menu') + + def ktrigger(self, evt): + return Binding.check(evt, self.cfg('bindings', 'misc', 'menu')) + + def apply(self): + x = self.pos[0] + self.width + y = self.pos[1] + self.height + self.root.context.show(x, y) + + +class ShellDrop(Action): + """Open a terminal shell.""" + text = kp.StringProperty('Shell') + + def apply(self): + from tagit import debug + debug(locals(), globals()) + + +class OpenExternal(Action): + """Open the selected items in an external application.""" + text = kp.StringProperty('Open') + + def ktrigger(self, evt): + # FIXME: Triggered on Shift + Click (Interferes with selection!) + # Triggered on when tags are edited. + return Binding.check(evt, self.cfg('bindings', 'misc', 'open')) + + def apply(self): + with self.root.browser as browser: + if browser.cursor is None: + logger.error('No file selected') + elif os.path.exists(browser.cursor.path): + fileopen(browser.cursor.path) + else: + logger.error('File unavailable') + + +class ShowConsole(Action): + """Open the log console.""" + text = kp.StringProperty('Console') + + def apply(self): + self.root.status.console() + + +class ShowHelp(Action): + """Show some help.""" + text = kp.StringProperty('Help') + + def ktrigger(self, evt): + return Binding.check(evt, self.cfg('bindings', 'misc', 'help')) + + def apply(self): + webbrowser.open(HELP_URL) + + +class ShowSettings(Action): + """Open the settings menu.""" + text = kp.StringProperty('Settings') + + def ktrigger(self, evt): + return Binding.check(evt, self.cfg('bindings', 'misc', 'settings')) + + def apply(self): + from kivy.app import App + App.get_running_app().open_settings() + + +class ClipboardCopy(Action): + """Copy selected items into the clipboard.""" + text = kp.StringProperty('Copy to clipboard') + + def ktrigger(self, evt): + return Binding.check(evt, self.cfg('bindings', 'clipboard', 'copy')) + + def apply(self): + browser = self.root.browser + paths = [obj.path for obj in browser.selection] + Clipboard.copy('\n'.join(paths)) + + +class ClipboardPaste(Action): + """Import items from the clipboard.""" + text = kp.StringProperty('Paste from clipboard') + + def ktrigger(self, evt): + return Binding.check(evt, self.cfg('bindings', 'clipboard', 'paste')) + + def apply(self): + paths = Clipboard.paste() + paths = paths.split('\n') + self.root.trigger('ImportObjects', paths) + + +## config ## + +# keybindings + +config.declare(('bindings', 'misc', 'menu'), + config.Keybind(), Binding.simple(Binding.CMD, None, Binding.mALL), + __name__, Menu.text.defaultvalue, Menu.__doc__) + +config.declare(('bindings', 'misc', 'open'), + config.Keybind(), Binding.simple(Binding.ENTER, None, Binding.mALL), + __name__, OpenExternal.text.defaultvalue, OpenExternal.__doc__) + +config.declare(('bindings', 'misc', 'help'), + config.Keybind(), Binding.simple('/', Binding.mSHIFT), + __name__, ShowHelp.text.defaultvalue, ShowHelp.__doc__) + +config.declare(('bindings', 'misc', 'settings'), + config.Keybind(), Binding.simple(Binding.F1), # also the kivy default + __name__, ShowSettings.text.defaultvalue, ShowSettings.__doc__) + +config.declare(('bindings', 'clipboard', 'copy'), + config.Keybind(), Binding.simple('c', Binding.mCTRL), + __name__, ClipboardCopy.text.defaultvalue, ClipboardCopy.__doc__) + +config.declare(('bindings', 'clipboard', 'paste'), + config.Keybind(), Binding.simple('v', Binding.mCTRL), + __name__, ClipboardPaste.text.defaultvalue, ClipboardPaste.__doc__) + +## EOF ## diff --git a/tagit/actions/planes.kv b/tagit/actions/planes.kv new file mode 100644 index 0000000..184f949 --- /dev/null +++ b/tagit/actions/planes.kv @@ -0,0 +1,15 @@ +#:import resource_find kivy.resources.resource_find + +: + source: resource_find('atlas://planes/dashboard') + tooltip: 'Switch to the Dashboard' + +: + source: resource_find('atlas://planes/browsing') + tooltip: 'Switch to the browsing plane' + +: + source: resource_find('atlas://planes/codash') + tooltip: 'Switch to the contextual dashboard' + +## EOF ## diff --git a/tagit/actions/planes.py b/tagit/actions/planes.py new file mode 100644 index 0000000..89f93bb --- /dev/null +++ b/tagit/actions/planes.py @@ -0,0 +1,57 @@ +""" + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 +""" +# standard imports +import os + +# kivy imports +from kivy.lang import Builder +import kivy.properties as kp + +# inner-module imports +from .action import Action + +# exports +__all__ = [] + + +## code ## + +# load kv +Builder.load_file(os.path.join(os.path.dirname(__file__), 'planes.kv')) + +# classes + +class ShowDashboard(Action): + """Switch to the dashboard.""" + text = kp.StringProperty('Dashboard') + + def apply(self): + planes = self.root.planes + if planes.current_slide != planes.dashboard: + planes.load_slide(planes.dashboard) + + +class ShowBrowsing(Action): + """Switch to the browsing plane.""" + text = kp.StringProperty('Browsing') + + def apply(self): + planes = self.root.planes + if planes.current_slide != planes.browsing: + planes.load_slide(planes.browsing) + + +class ShowCodash(Action): + """Switch to the contextual dashboard.""" + text = kp.StringProperty('Context') + + def apply(self): + planes = self.root.planes + if planes.current_slide != planes.codash: + planes.load_slide(planes.codash) + +## EOF ## diff --git a/tagit/config/loader.py b/tagit/config/loader.py index 489b063..87ac328 100644 --- a/tagit/config/loader.py +++ b/tagit/config/loader.py @@ -17,7 +17,7 @@ from .settings import Settings TAGITRC = '.tagitrc' -DEFAULT_USER_CONFIG = os.path.join(os.path.dirname(__file__), 'user-defaults.json') +DEFAULT_USER_CONFIG = os.path.join(os.path.dirname(__file__), 'user-defaults.yaml') SETTINGS_PATH = [ # user home @@ -26,7 +26,7 @@ SETTINGS_PATH = [ '/usr/share/tagit/settings', '/usr/share/tagit/keybindings', # module defaults - os.path.join(os.path.dirname(__file__), 'settings.json'), + os.path.join(os.path.dirname(__file__), 'settings.yaml'), ] # exports diff --git a/tagit/config/port-config.yaml b/tagit/config/port-config.yaml new file mode 100644 index 0000000..038dc07 --- /dev/null +++ b/tagit/config/port-config.yaml @@ -0,0 +1,114 @@ +session: + first_start: false + paths: + searchlog: ~/.tagit.log +storage: + index: + preview_size: + - 50 + - 200 + - 400 +ui: + standalone: + plane: browsing + browser: + maxcols: 8 + maxrows: 8 + buttondocks: + sidebar_left: [] + #- Menu + #- ShowDashboard + #- AddTag + #- EditTag + #- CreateGroup + #- DissolveGroup + #- SelectAll + #- SelectNone + #- SelectInvert + #- SelectAdditive + #- SelectSubtractive + #- SelectSingle + #- SelectMulti + #- SelectRange + context: + app: + - ShowSettings + - ShowHelp + - ShowConsole + - ShowBrowsing + # browser: + # - ZoomIn + # - ZoomOut + # clipboard: + # - ClipboardCopy + # - ClipboardPaste + # grouping: + # - CreateGroup + # - DissolveGroup + # - AddToGroup + # - RepresentGroup + # - RemoveFromGroup + # root: + # - CloseSessionAndExit + # search: + # - ShowSelected + # - RemoveSelected + # select: + # - SelectAll + # - SelectNone + # - SelectInvert + # - SelectSingle + # - SelectMulti + # - SelectRange + # - SelectAdditive + # - SelectSubtractive + # session: + # - SaveSession + # - SaveSessionAs + # - ItemExport + # - ImportObjects + # tagging: + # - AddTag + # - EditTag + # - SetRank1 + # - SetRank3 + # - SetRank5 + search: + sort_blacklist: + - entity + - flash + - latitude + - longitude + - mime + - author + - camera + - attributes + tabs: + max: 2 + tiledocks: + dashboard: {} + #Buttons: + # buttons: + # - ShowBrowsing + # - CreateSession + # - CreateTempSession + # - LoadSession + # - ReloadSession + # - ImportObjects + # - SaveSession + # - SaveSessionAs + # - ItemExport + # - UpdateSelectedObjects + # - SyncSelectedObjects + # - ShowHelp + # - ShowSettings + #Hints: {} + #LibSummary: {} + #Searchtree: {} + #TagHistogram: {} + #Tagcloud: {} + sidebar_right: {} + #CursorTags: {} + #Info: {} + #Venn: {} + window_size: 1024x768 diff --git a/tagit/config/settings.py b/tagit/config/settings.py index 21ab594..190268c 100644 --- a/tagit/config/settings.py +++ b/tagit/config/settings.py @@ -12,6 +12,9 @@ import json import os import typing +# external imports +import yaml # FIXME: mb/port/convenicence + # tagit imports from tagit.utils import errors, fst, is_list @@ -60,11 +63,14 @@ class Settings(abc.MutableMapping, abc.Hashable, abc.Callable): if os.path.exists(source): config_path = os.path.realpath(source) with open(source, 'r') as ifile: - config = json.load(ifile) + #config = json.load(ifile) + config = yaml.safe_load(ifile) # FIXME: mb/port/convenicence else: - config = json.loads(source) + #config = json.loads(source) + config = yaml.safe_load(source) # FIXME: mb/port/convenicence elif isinstance(source, io.TextIOBase): # opened file - config = json.load(source) + #config = json.load(source) + config = yaml.safe_load(source) # FIXME: mb/port/convenicence else: raise TypeError('expected dict, path, or file-like') diff --git a/tagit/config/user-defaults.yaml b/tagit/config/user-defaults.yaml new file mode 100644 index 0000000..b7a70c4 --- /dev/null +++ b/tagit/config/user-defaults.yaml @@ -0,0 +1,112 @@ +session: + first_start: false + paths: + searchlog: ~/.tagit.log +storage: + index: + preview_size: + - 50 + - 200 + - 400 +ui: + standalone: + browser: + maxcols: 8 + maxrows: 8 + buttondocks: + sidebar_left: + - Menu + - ShowDashboard + - AddTag + - EditTag + - CreateGroup + - DissolveGroup + - SelectAll + - SelectNone + - SelectInvert + - SelectAdditive + - SelectSubtractive + - SelectSingle + - SelectMulti + - SelectRange + context: + app: + - ShowSettings + - ShowHelp + - ShowConsole + browser: + - ZoomIn + - ZoomOut + clipboard: + - ClipboardCopy + - ClipboardPaste + grouping: + - CreateGroup + - DissolveGroup + - AddToGroup + - RepresentGroup + - RemoveFromGroup + root: + - CloseSessionAndExit + search: + - ShowSelected + - RemoveSelected + select: + - SelectAll + - SelectNone + - SelectInvert + - SelectSingle + - SelectMulti + - SelectRange + - SelectAdditive + - SelectSubtractive + session: + - SaveSession + - SaveSessionAs + - ItemExport + - ImportObjects + tagging: + - AddTag + - EditTag + - SetRank1 + - SetRank3 + - SetRank5 + search: + sort_blacklist: + - entity + - flash + - latitude + - longitude + - mime + - author + - camera + - attributes + tabs: + max: 2 + tiledocks: + dashboard: + Buttons: + buttons: + - ShowBrowsing + - CreateSession + - CreateTempSession + - LoadSession + - ReloadSession + - ImportObjects + - SaveSession + - SaveSessionAs + - ItemExport + - UpdateSelectedObjects + - SyncSelectedObjects + - ShowHelp + - ShowSettings + Hints: {} + LibSummary: {} + Searchtree: {} + TagHistogram: {} + Tagcloud: {} + sidebar_right: + CursorTags: {} + Info: {} + Venn: {} + window_size: 1024x768 -- cgit v1.2.3 From 74ea37edd18c55c6b0ba66405149530d0a5d5153 Mon Sep 17 00:00:00 2001 From: Matthias Baumgartner Date: Sat, 7 Jan 2023 01:49:42 +0100 Subject: assets --- tagit/__init__.py | 30 ++- tagit/assets/fonts/kivy/Unifont.ttf | Bin 0 -> 12291788 bytes tagit/assets/icons/kivy/make.sh | 27 +++ tagit/assets/icons/kivy/misc-0.png | Bin 0 -> 5845 bytes tagit/assets/icons/kivy/misc.atlas | 1 + tagit/assets/icons/kivy/planes-0.png | Bin 0 -> 2261 bytes tagit/assets/icons/kivy/planes.atlas | 1 + tagit/assets/icons/scalable/misc/clip_copy.svg | 204 +++++++++++++++++++++ tagit/assets/icons/scalable/misc/clip_paste.svg | 163 ++++++++++++++++ tagit/assets/icons/scalable/misc/console.svg | 153 ++++++++++++++++ tagit/assets/icons/scalable/misc/help.svg | 113 ++++++++++++ tagit/assets/icons/scalable/misc/internal.svg | 111 +++++++++++ tagit/assets/icons/scalable/misc/menu.svg | 154 ++++++++++++++++ tagit/assets/icons/scalable/misc/open_external.svg | 198 ++++++++++++++++++++ tagit/assets/icons/scalable/misc/settings.svg | 109 +++++++++++ tagit/assets/icons/scalable/misc/shell.svg | 152 +++++++++++++++ tagit/assets/icons/scalable/planes/browsing.svg | 157 ++++++++++++++++ tagit/assets/icons/scalable/planes/codash.svg | 147 +++++++++++++++ tagit/assets/icons/scalable/planes/dashboard.svg | 142 ++++++++++++++ 19 files changed, 1861 insertions(+), 1 deletion(-) create mode 100644 tagit/assets/fonts/kivy/Unifont.ttf create mode 100755 tagit/assets/icons/kivy/make.sh create mode 100644 tagit/assets/icons/kivy/misc-0.png create mode 100644 tagit/assets/icons/kivy/misc.atlas create mode 100644 tagit/assets/icons/kivy/planes-0.png create mode 100644 tagit/assets/icons/kivy/planes.atlas create mode 100644 tagit/assets/icons/scalable/misc/clip_copy.svg create mode 100644 tagit/assets/icons/scalable/misc/clip_paste.svg create mode 100644 tagit/assets/icons/scalable/misc/console.svg create mode 100644 tagit/assets/icons/scalable/misc/help.svg create mode 100644 tagit/assets/icons/scalable/misc/internal.svg create mode 100644 tagit/assets/icons/scalable/misc/menu.svg create mode 100644 tagit/assets/icons/scalable/misc/open_external.svg create mode 100644 tagit/assets/icons/scalable/misc/settings.svg create mode 100644 tagit/assets/icons/scalable/misc/shell.svg create mode 100644 tagit/assets/icons/scalable/planes/browsing.svg create mode 100644 tagit/assets/icons/scalable/planes/codash.svg create mode 100644 tagit/assets/icons/scalable/planes/dashboard.svg (limited to 'tagit') diff --git a/tagit/__init__.py b/tagit/__init__.py index dda8ea7..3b1c21c 100644 --- a/tagit/__init__.py +++ b/tagit/__init__.py @@ -1,4 +1,4 @@ -""" +"""Tagit standalone user interfaces. Part of the tagit module. A copy of the license is provided with the project. @@ -6,8 +6,13 @@ Author: Matthias Baumgartner, 2022 """ # standard imports import collections +import os import typing +# kivy imports +from kivy.resources import resource_add_path +import kivy + # constants T_VERSION_INFO = collections.namedtuple('T_VERSION_INFO', ('major', 'minor', 'micro')) version_info = T_VERSION_INFO(0, 0, 1) @@ -15,4 +20,27 @@ version_info = T_VERSION_INFO(0, 0, 1) # exports __all__: typing.Sequence[str] = [] + +## code ## + +# check kivy version +kivy.require('1.9.1') + +# add resources +resource_add_path(os.path.join(os.path.dirname(__file__), 'assets', 'icons', 'kivy')) +resource_add_path(os.path.join(os.path.dirname(__file__), 'assets', 'fonts', 'kivy')) + +# load font +from kivy.core.text import LabelBase +LabelBase.register(name='Unifont', fn_regular='Unifont.ttf') + +# console logging fix: +# kivy adds an extra whitespace in front of tagit log entries. +# Adding a carriage return in front of the log fixes this bug. +# This is only needed for the console log handler, not others. +# Note that this mechanism is repeated in apps/gui to +# achieve the same for user-defined log handlers. +#from tagit import loghandler # FIXME: mb/port +#loghandler.formatter.prefix = '\r' # FIXME: mb/port + ## EOF ## diff --git a/tagit/assets/fonts/kivy/Unifont.ttf b/tagit/assets/fonts/kivy/Unifont.ttf new file mode 100644 index 0000000..ec875c5 Binary files /dev/null and b/tagit/assets/fonts/kivy/Unifont.ttf differ diff --git a/tagit/assets/icons/kivy/make.sh b/tagit/assets/icons/kivy/make.sh new file mode 100755 index 0000000..d5f1ebe --- /dev/null +++ b/tagit/assets/icons/kivy/make.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +# convert svg to png +for prefix in `find ../scalable/* -type d`; do + # make directory + dirname=$(basename "${prefix}") + mkdir -p "${dirname}" + + # convert svg to png + for path in `find "${prefix}" -type f -iname "*.svg"`; do + # keep the file name but change the extension to png + filename=$(basename "${path}") + filename="${filename/\.svg/.png}" + inkscape -z -w 30 -h 30 "${path}" -e "${dirname}/${filename}" + done +done + +# create atlas through kivy +for folder in `ls -d */`; do + # get the atlas width from the file count + count=$(ls "${folder}"/*.png | wc -l) + width=$(expr 32 \* "${count}") + name=$(basename "${folder}") + # create the atlas + python -m kivy.atlas "${name}" "${width}x32" "${folder}"*.png + +done diff --git a/tagit/assets/icons/kivy/misc-0.png b/tagit/assets/icons/kivy/misc-0.png new file mode 100644 index 0000000..5cbfda9 Binary files /dev/null and b/tagit/assets/icons/kivy/misc-0.png differ diff --git a/tagit/assets/icons/kivy/misc.atlas b/tagit/assets/icons/kivy/misc.atlas new file mode 100644 index 0000000..23b4f37 --- /dev/null +++ b/tagit/assets/icons/kivy/misc.atlas @@ -0,0 +1 @@ +{"misc-0.png": {"clip_copy": [2, 0, 30, 30], "clip_paste": [34, 0, 30, 30], "console": [66, 0, 30, 30], "help": [98, 0, 30, 30], "internal": [130, 0, 30, 30], "menu": [162, 0, 30, 30], "open_external": [194, 0, 30, 30], "settings": [226, 0, 30, 30], "shell": [258, 0, 30, 30]}} \ No newline at end of file diff --git a/tagit/assets/icons/kivy/planes-0.png b/tagit/assets/icons/kivy/planes-0.png new file mode 100644 index 0000000..a999f9a Binary files /dev/null and b/tagit/assets/icons/kivy/planes-0.png differ diff --git a/tagit/assets/icons/kivy/planes.atlas b/tagit/assets/icons/kivy/planes.atlas new file mode 100644 index 0000000..811d2df --- /dev/null +++ b/tagit/assets/icons/kivy/planes.atlas @@ -0,0 +1 @@ +{"planes-0.png": {"browsing": [2, 0, 30, 30], "codash": [34, 0, 30, 30], "dashboard": [66, 0, 30, 30]}} \ No newline at end of file diff --git a/tagit/assets/icons/scalable/misc/clip_copy.svg b/tagit/assets/icons/scalable/misc/clip_copy.svg new file mode 100644 index 0000000..d90de47 --- /dev/null +++ b/tagit/assets/icons/scalable/misc/clip_copy.svg @@ -0,0 +1,204 @@ + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tagit/assets/icons/scalable/misc/clip_paste.svg b/tagit/assets/icons/scalable/misc/clip_paste.svg new file mode 100644 index 0000000..8747068 --- /dev/null +++ b/tagit/assets/icons/scalable/misc/clip_paste.svg @@ -0,0 +1,163 @@ + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + diff --git a/tagit/assets/icons/scalable/misc/console.svg b/tagit/assets/icons/scalable/misc/console.svg new file mode 100644 index 0000000..35f30e2 --- /dev/null +++ b/tagit/assets/icons/scalable/misc/console.svg @@ -0,0 +1,153 @@ + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + >_ + + + diff --git a/tagit/assets/icons/scalable/misc/help.svg b/tagit/assets/icons/scalable/misc/help.svg new file mode 100644 index 0000000..ae84a45 --- /dev/null +++ b/tagit/assets/icons/scalable/misc/help.svg @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + image/svg+xml + + + + + + + ? + + diff --git a/tagit/assets/icons/scalable/misc/internal.svg b/tagit/assets/icons/scalable/misc/internal.svg new file mode 100644 index 0000000..9a585b2 --- /dev/null +++ b/tagit/assets/icons/scalable/misc/internal.svg @@ -0,0 +1,111 @@ + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/tagit/assets/icons/scalable/misc/menu.svg b/tagit/assets/icons/scalable/misc/menu.svg new file mode 100644 index 0000000..e2db5ed --- /dev/null +++ b/tagit/assets/icons/scalable/misc/menu.svg @@ -0,0 +1,154 @@ + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/tagit/assets/icons/scalable/misc/open_external.svg b/tagit/assets/icons/scalable/misc/open_external.svg new file mode 100644 index 0000000..93e42cc --- /dev/null +++ b/tagit/assets/icons/scalable/misc/open_external.svg @@ -0,0 +1,198 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/tagit/assets/icons/scalable/misc/settings.svg b/tagit/assets/icons/scalable/misc/settings.svg new file mode 100644 index 0000000..b821de2 --- /dev/null +++ b/tagit/assets/icons/scalable/misc/settings.svg @@ -0,0 +1,109 @@ + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/tagit/assets/icons/scalable/misc/shell.svg b/tagit/assets/icons/scalable/misc/shell.svg new file mode 100644 index 0000000..eabd1a4 --- /dev/null +++ b/tagit/assets/icons/scalable/misc/shell.svg @@ -0,0 +1,152 @@ + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + > ... + + + diff --git a/tagit/assets/icons/scalable/planes/browsing.svg b/tagit/assets/icons/scalable/planes/browsing.svg new file mode 100644 index 0000000..f502c36 --- /dev/null +++ b/tagit/assets/icons/scalable/planes/browsing.svg @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/tagit/assets/icons/scalable/planes/codash.svg b/tagit/assets/icons/scalable/planes/codash.svg new file mode 100644 index 0000000..b25c2b0 --- /dev/null +++ b/tagit/assets/icons/scalable/planes/codash.svg @@ -0,0 +1,147 @@ + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + c + + diff --git a/tagit/assets/icons/scalable/planes/dashboard.svg b/tagit/assets/icons/scalable/planes/dashboard.svg new file mode 100644 index 0000000..6f7e4a3 --- /dev/null +++ b/tagit/assets/icons/scalable/planes/dashboard.svg @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + -- 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/actions/__init__.py | 5 ----- tagit/config/port-config.yaml | 5 +++-- tagit/config/user-defaults.json | 3 --- tagit/config/user-defaults.yaml | 2 -- tagit/widgets/desktop.kv | 24 +++++++++++++++--------- tagit/widgets/desktop.py | 1 - tagit/widgets/tabs.kv | 31 ------------------------------- tagit/widgets/tabs.py | 37 ------------------------------------- 8 files changed, 18 insertions(+), 90 deletions(-) delete mode 100644 tagit/widgets/tabs.kv delete mode 100644 tagit/widgets/tabs.py (limited to 'tagit') diff --git a/tagit/actions/__init__.py b/tagit/actions/__init__.py index 9fd6342..ecff701 100644 --- a/tagit/actions/__init__.py +++ b/tagit/actions/__init__.py @@ -20,7 +20,6 @@ from . import misc from . import planes from . import search #from . import session -#from . import tabs # exports __all__: typing.Sequence[str] = ( @@ -122,10 +121,6 @@ class ActionBuilder(BuilderBase): #'CreateTempSession': session.CreateTempSession, #'ReloadSession': session.ReloadSession, #'CloseSessionAndExit': session.CloseSessionAndExit, - ## tabs - #'AddTab': tabs.AddTab, - #'CloseTab': tabs.CloseTab, - #'SwitchTab': tabs.SwitchTab, } ## EOF ## diff --git a/tagit/config/port-config.yaml b/tagit/config/port-config.yaml index 038dc07..d05a498 100644 --- a/tagit/config/port-config.yaml +++ b/tagit/config/port-config.yaml @@ -83,8 +83,9 @@ ui: - author - camera - attributes - tabs: - max: 2 + # FIXME: mb/port/tabs + #tabs: + # max: 2 tiledocks: dashboard: {} #Buttons: diff --git a/tagit/config/user-defaults.json b/tagit/config/user-defaults.json index b76ef2b..5a39311 100644 --- a/tagit/config/user-defaults.json +++ b/tagit/config/user-defaults.json @@ -39,9 +39,6 @@ "SelectRange" ] }, - "tabs": { - "max": 2 - }, "tiledocks": { "dashboard": { "Buttons": { diff --git a/tagit/config/user-defaults.yaml b/tagit/config/user-defaults.yaml index b7a70c4..62b8ed6 100644 --- a/tagit/config/user-defaults.yaml +++ b/tagit/config/user-defaults.yaml @@ -81,8 +81,6 @@ ui: - author - camera - attributes - tabs: - max: 2 tiledocks: dashboard: Buttons: 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 3aebfcba6afd5a882f02e55e442580fe0290c776 Mon Sep 17 00:00:00 2001 From: Matthias Baumgartner Date: Sat, 7 Jan 2023 16:02:33 +0100 Subject: pin to port config --- tagit/apps/desktop.py | 8 +++- tagit/apps/port-config.yaml | 112 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 tagit/apps/port-config.yaml (limited to 'tagit') diff --git a/tagit/apps/desktop.py b/tagit/apps/desktop.py index 21a610c..763439c 100644 --- a/tagit/apps/desktop.py +++ b/tagit/apps/desktop.py @@ -5,6 +5,7 @@ A copy of the license is provided with the project. Author: Matthias Baumgartner, 2022 """ # standard imports +import os import typing # kivy imports @@ -34,8 +35,11 @@ class TagitApp(App): # FIXME: mb/port # load essentials - from tagit.config.loader import load_settings, TAGITRC - cfg = load_settings(TAGITRC, 0) + + #from tagit.config.loader import load_settings, TAGITRC + #cfg = load_settings(TAGITRC, 0) + from tagit.config import Settings + cfg = Settings.Open(os.path.join(os.path.dirname(__file__), 'port-config.yaml')) # create widget return desktop.MainWindow(cfg, None, None) # FIXME: expects cfg, stor, log arguments diff --git a/tagit/apps/port-config.yaml b/tagit/apps/port-config.yaml new file mode 100644 index 0000000..ac0d242 --- /dev/null +++ b/tagit/apps/port-config.yaml @@ -0,0 +1,112 @@ +session: + first_start: false + paths: + searchlog: ~/.tagit.log +storage: + index: + preview_size: + - 50 + - 200 + - 400 +ui: + standalone: + plane: browsing + browser: + maxcols: 8 + maxrows: 8 + buttondocks: + sidebar_left: [] + #- Menu + #- ShowDashboard + #- AddTag + #- EditTag + #- CreateGroup + #- DissolveGroup + #- SelectAll + #- SelectNone + #- SelectInvert + #- SelectAdditive + #- SelectSubtractive + #- SelectSingle + #- SelectMulti + #- SelectRange + context: + app: + - ShowSettings + - ShowHelp + - ShowConsole + - ShowBrowsing + # browser: + # - ZoomIn + # - ZoomOut + # clipboard: + # - ClipboardCopy + # - ClipboardPaste + # grouping: + # - CreateGroup + # - DissolveGroup + # - AddToGroup + # - RepresentGroup + # - RemoveFromGroup + # root: + # - CloseSessionAndExit + # search: + # - ShowSelected + # - RemoveSelected + # select: + # - SelectAll + # - SelectNone + # - SelectInvert + # - SelectSingle + # - SelectMulti + # - SelectRange + # - SelectAdditive + # - SelectSubtractive + # session: + # - SaveSession + # - SaveSessionAs + # - ItemExport + # - ImportObjects + # tagging: + # - AddTag + # - EditTag + # - SetRank1 + # - SetRank3 + # - SetRank5 + search: + sort_blacklist: + - entity + - flash + - latitude + - longitude + - mime + - author + - camera + - attributes + tiledocks: + dashboard: {} + #Buttons: + # buttons: + # - ShowBrowsing + # - CreateSession + # - CreateTempSession + # - LoadSession + # - ReloadSession + # - ImportObjects + # - SaveSession + # - SaveSessionAs + # - ItemExport + # - UpdateSelectedObjects + # - SyncSelectedObjects + # - ShowHelp + # - ShowSettings + #Hints: {} + #LibSummary: {} + #Searchtree: {} + #TagHistogram: {} + #Tagcloud: {} + sidebar_right: {} + #CursorTags: {} + #Info: {} + #Venn: {} + window_size: 1024x768 -- cgit v1.2.3 From 37f1ac3f456b6677d9ce14274f3987ccda752f03 Mon Sep 17 00:00:00 2001 From: Matthias Baumgartner Date: Sat, 7 Jan 2023 16:26:26 +0100 Subject: browser and misc actions --- tagit/actions/__init__.py | 60 +- tagit/actions/action.kv | 45 ++ tagit/actions/action.py | 257 +++++++++ tagit/actions/browser.kv | 99 ++++ tagit/actions/browser.py | 628 +++++++++++++++++++++ tagit/actions/misc.py | 3 +- tagit/apps/port-config.yaml | 6 +- tagit/assets/icons/kivy/browser-0.png | Bin 0 -> 7317 bytes tagit/assets/icons/kivy/browser.atlas | 1 + tagit/assets/icons/scalable/README | 5 + .../assets/icons/scalable/browser/cursor_down.svg | 164 ++++++ .../assets/icons/scalable/browser/cursor_first.svg | 155 +++++ .../assets/icons/scalable/browser/cursor_last.svg | 154 +++++ .../assets/icons/scalable/browser/cursor_left.svg | 159 ++++++ .../assets/icons/scalable/browser/cursor_right.svg | 163 ++++++ tagit/assets/icons/scalable/browser/cursor_up.svg | 163 ++++++ tagit/assets/icons/scalable/browser/next_page.svg | 164 ++++++ .../icons/scalable/browser/previous_page.svg | 164 ++++++ .../assets/icons/scalable/browser/scroll_down.svg | 147 +++++ tagit/assets/icons/scalable/browser/scroll_up.svg | 147 +++++ tagit/assets/icons/scalable/browser/select.svg | 291 ++++++++++ tagit/assets/icons/scalable/browser/select_add.svg | 169 ++++++ tagit/assets/icons/scalable/browser/select_all.svg | 158 ++++++ .../icons/scalable/browser/select_invert.svg | 255 +++++++++ .../assets/icons/scalable/browser/select_multi.svg | 158 ++++++ .../assets/icons/scalable/browser/select_none.svg | 158 ++++++ .../assets/icons/scalable/browser/select_range.svg | 312 ++++++++++ .../icons/scalable/browser/select_single.svg | 158 ++++++ tagit/assets/icons/scalable/browser/select_sub.svg | 162 ++++++ tagit/assets/icons/scalable/browser/zoom_in.svg | 241 ++++++++ tagit/assets/icons/scalable/browser/zoom_out.svg | 241 ++++++++ tagit/config/port-config.yaml | 115 ---- tagit/utils/shared.py | 17 + 33 files changed, 4969 insertions(+), 150 deletions(-) create mode 100644 tagit/actions/action.kv create mode 100644 tagit/actions/action.py create mode 100644 tagit/actions/browser.kv create mode 100644 tagit/actions/browser.py create mode 100644 tagit/assets/icons/kivy/browser-0.png create mode 100644 tagit/assets/icons/kivy/browser.atlas create mode 100644 tagit/assets/icons/scalable/README create mode 100644 tagit/assets/icons/scalable/browser/cursor_down.svg create mode 100644 tagit/assets/icons/scalable/browser/cursor_first.svg create mode 100644 tagit/assets/icons/scalable/browser/cursor_last.svg create mode 100644 tagit/assets/icons/scalable/browser/cursor_left.svg create mode 100644 tagit/assets/icons/scalable/browser/cursor_right.svg create mode 100644 tagit/assets/icons/scalable/browser/cursor_up.svg create mode 100644 tagit/assets/icons/scalable/browser/next_page.svg create mode 100644 tagit/assets/icons/scalable/browser/previous_page.svg create mode 100644 tagit/assets/icons/scalable/browser/scroll_down.svg create mode 100644 tagit/assets/icons/scalable/browser/scroll_up.svg create mode 100644 tagit/assets/icons/scalable/browser/select.svg create mode 100644 tagit/assets/icons/scalable/browser/select_add.svg create mode 100644 tagit/assets/icons/scalable/browser/select_all.svg create mode 100644 tagit/assets/icons/scalable/browser/select_invert.svg create mode 100644 tagit/assets/icons/scalable/browser/select_multi.svg create mode 100644 tagit/assets/icons/scalable/browser/select_none.svg create mode 100644 tagit/assets/icons/scalable/browser/select_range.svg create mode 100644 tagit/assets/icons/scalable/browser/select_single.svg create mode 100644 tagit/assets/icons/scalable/browser/select_sub.svg create mode 100644 tagit/assets/icons/scalable/browser/zoom_in.svg create mode 100644 tagit/assets/icons/scalable/browser/zoom_out.svg delete mode 100644 tagit/config/port-config.yaml (limited to 'tagit') diff --git a/tagit/actions/__init__.py b/tagit/actions/__init__.py index ecff701..876ca1f 100644 --- a/tagit/actions/__init__.py +++ b/tagit/actions/__init__.py @@ -11,7 +11,7 @@ import typing from tagit.utils.builder import BuilderBase # inner-module imports -#from . import browser +from . import browser from . import filter from . import grouping #from . import library @@ -32,30 +32,30 @@ __all__: typing.Sequence[str] = ( class ActionBuilder(BuilderBase): _factories = { ## browser - #'NextPage': browser.NextPage, - #'PreviousPage': browser.PreviousPage, - #'ScrollDown': browser.ScrollDown, - #'ScrollUp': browser.ScrollUp, - #'JumpToPage': browser.JumpToPage, - #'SetCursor': browser.SetCursor, - #'ZoomIn': browser.ZoomIn, - #'ZoomOut': browser.ZoomOut, - #'JumpToCursor': browser.JumpToCursor, - #'MoveCursorFirst': browser.MoveCursorFirst, - #'MoveCursorLast': browser.MoveCursorLast, - #'MoveCursorUp': browser.MoveCursorUp, - #'MoveCursorDown': browser.MoveCursorDown, - #'MoveCursorLeft': browser.MoveCursorLeft, - #'MoveCursorRight': browser.MoveCursorRight, - #'SelectRange': browser.SelectRange, - #'SelectAdditive': browser.SelectAdditive, - #'SelectSubtractive': browser.SelectSubtractive, - #'SelectMulti': browser.SelectMulti, - #'SelectSingle': browser.SelectSingle, - #'SelectAll': browser.SelectAll, - #'SelectNone': browser.SelectNone, - #'SelectInvert': browser.SelectInvert, - #'Select': browser.Select, + 'NextPage': browser.NextPage, + 'PreviousPage': browser.PreviousPage, + 'ScrollDown': browser.ScrollDown, + 'ScrollUp': browser.ScrollUp, + 'JumpToPage': browser.JumpToPage, + 'SetCursor': browser.SetCursor, + 'ZoomIn': browser.ZoomIn, + 'ZoomOut': browser.ZoomOut, + 'JumpToCursor': browser.JumpToCursor, + 'MoveCursorFirst': browser.MoveCursorFirst, + 'MoveCursorLast': browser.MoveCursorLast, + 'MoveCursorUp': browser.MoveCursorUp, + 'MoveCursorDown': browser.MoveCursorDown, + 'MoveCursorLeft': browser.MoveCursorLeft, + 'MoveCursorRight': browser.MoveCursorRight, + 'SelectRange': browser.SelectRange, + 'SelectAdditive': browser.SelectAdditive, + 'SelectSubtractive': browser.SelectSubtractive, + 'SelectMulti': browser.SelectMulti, + 'SelectSingle': browser.SelectSingle, + 'SelectAll': browser.SelectAll, + 'SelectNone': browser.SelectNone, + 'SelectInvert': browser.SelectInvert, + 'Select': browser.Select, ## filter #'AddToken': filter.AddToken, #'RemoveToken': filter.RemoveToken, @@ -84,14 +84,14 @@ class ActionBuilder(BuilderBase): #'SyncObjects': library.SyncObjects, #'ItemExport': library.ItemExport, ## misc - #'ShellDrop': misc.ShellDrop, - #'OpenExternal': misc.OpenExternal, - #'Menu': misc.Menu, + 'ShellDrop': misc.ShellDrop, + 'OpenExternal': misc.OpenExternal, + 'Menu': misc.Menu, 'ShowConsole': misc.ShowConsole, 'ShowHelp': misc.ShowHelp, 'ShowSettings': misc.ShowSettings, - #'ClipboardCopy': misc.ClipboardCopy, - #'ClipboardPaste': misc.ClipboardPaste, + 'ClipboardCopy': misc.ClipboardCopy, + 'ClipboardPaste': misc.ClipboardPaste, ## objects #'RotateLeft': objects.RotateLeft, #'RotateRight': objects.RotateRight, diff --git a/tagit/actions/action.kv b/tagit/actions/action.kv new file mode 100644 index 0000000..5352964 --- /dev/null +++ b/tagit/actions/action.kv @@ -0,0 +1,45 @@ + +: + # internas + orientation: 'horizontal' + + # responsiveness + # *touch_trigger* is enabled automatically if an image or text is shown. + # If that is undesired, *touch_trigger* has to be disabled **after** the + # declaration of *show*. + key_trigger: True + touch_trigger: False + + # size + # By default the width expands as necessary. To get a fixed width, + # set the width manually or via size_hint_x and set *autowidth* to False. + # If something is shown, *height* is automatically set to *default_height* + # unless specified otherwise in by the caller. + default_height: 30 + size: 0, 0 + size_hint: None, None + autowidth: True + + # behaviour + # The default is that no buttons are shown and touch triggers are disabled. + # NOTE: Callers need to declare show **last** to ensure that all other + # properties are set. The only exception is *touch_trigger* which has + # to be disabled **after** show. + show: [] + + # decoration + canvas.before: + Color: + rgba: 17 / 255, 32 / 255, 148 / 255, self.selected_alpha + Rectangle: + pos: self.x, self.y + 1 + size: self.size + + canvas.after: + Color: + rgba: 17 / 255, 32 / 255, 148 / 255, self.selected_alpha + Line: + width: 2 + rectangle: self.x, self.y, self.width, self.height + + ## EOF ## diff --git a/tagit/actions/action.py b/tagit/actions/action.py new file mode 100644 index 0000000..e8866ce --- /dev/null +++ b/tagit/actions/action.py @@ -0,0 +1,257 @@ +"""Button for proxy actions. + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 +""" +# standard imports +import logging +import os + +# kivy imports +from kivy import metrics +from kivy.animation import Animation +from kivy.lang import Builder +from kivy.uix.behaviors import ButtonBehavior +from kivy.uix.boxlayout import BoxLayout +from kivy.uix.image import Image +from kivy.uix.label import Label +import kivy.properties as kp + +# tagit imports +from tagit.external.tooltip import Tooltip + +# exports +__all__ = ('Action', ) + + +## code ## + +logger = logging.getLogger(__name__) + +# load kv +Builder.load_file(os.path.join(os.path.dirname(__file__), 'action.kv')) + +class Action(ButtonBehavior, BoxLayout, Tooltip): + """ + + An Action can be triggered in three ways: + * Touch event: Clicking or touching on the button + * Key event: Entering a keyboard shortcut + * Single-shot: Programmatically triggered once, without UI attachment + + For the last, use the *single_shot* classmethod. + + For the first two, declare the Action in a kv file or in code. + Note that the remarks below about kv do not apply if the object is + created in code. + + + When an Action is declared in kv, two restrictions apply (also see + examples below): + * To disable touch_trigger, it must be declared last + * show must be declared after all other properties + + Enable key triggers, but hide the Action in the UI: + Action: + show: [] + + Action: + # alias for the one above + + Show text, image, or both, with default height and the width + stretched as necessary: + Action: + show: 'text', + + Action: + show: 'image', + + Action: + show: 'text', 'image' + + Action: + width: 200 # has no effect unless autowidth is False + show: 'image', 'text' + + Make the Action larger: + Action: + # increased height. The image width scales accordingly + height: 80 + show: 'image', 'text' + + Action: + # scales to parent widget's width + autowidth: False + size_hint_x: 1 + show: 'image', 'text' + + Action: + # fixed width and height + width: 150 + height: 80 + autowidth: False + show: 'image', 'text' # must be declared **last** + + Show the button but disable touch events: + Action: + height: 80 + show: 'image', 'text' + touch_trigger: False # must be declared **after** show + + Do the same in code: + >>> Action( + ... size=(130, 80), + ... autowidth=False, + ... show=('image', 'text'), + ... text='foobar', + ... touch_trigger=False) + + """ + # content + tooltip = kp.StringProperty() + source = kp.StringProperty() + text = kp.StringProperty() + + # visibility flags + show = kp.ListProperty([]) + + # sizing + default_height = kp.NumericProperty(30) + autowidth = kp.BooleanProperty(True) + + # responsiveness + key_trigger = kp.BooleanProperty(True) + touch_trigger = kp.BooleanProperty(True) + + # required such that properties can be set via constructor + font_size = kp.StringProperty("15sp") + # FIXME: Check why I have to pass size instead of height/width + height = kp.NumericProperty(0) + width = kp.NumericProperty(0) + + # internal properties + root = kp.ObjectProperty(None) + selected_alpha = kp.NumericProperty(0) + _image = kp.ObjectProperty(None) + _label = kp.ObjectProperty(None) + + def __init__(self, **kwargs): + show = kwargs.pop('show', []) + touch_trigger = kwargs.pop('touch_trigger', None) + super(Action, self).__init__(**kwargs) + # delay such that on_show is executed once the other + # properties are set + self.show = show + if touch_trigger is not None: + self.touch_trigger = touch_trigger + + ## display + + def on_show(self, wx, show): + if self._image is not None: + self.remove_widget(self._image) + if self._label is not None: + self.remove_widget(self._label) + + if 'image' in show: + self.height = self.default_height if self.height == 0 else self.height + self.touch_trigger = True + self._image = Image( + source=self.source, + # size + height=self.height, + width=self.height, # square image + size_hint=(None, None), + ) + self.add_widget(self._image) + if self.autowidth: + self.width = self.height + if 'text' in show: + self.height = self.default_height if self.height == 0 else self.height + self.touch_trigger = True + self._label = Label( + # text + text=self.text, + halign='left', + valign='middle', + padding=(metrics.dp(10), 0), + # size + font_size=self.font_size, + height=self.height, + size_hint=(None, None), + ) + self._label.bind(texture_size=self.on_texture_size) + self.add_widget(self._label) + + def on_size(self, wx, size): + for child in self.children: + child.height = self.height + if isinstance(child, Image): + child.width = self.height + elif not self.autowidth: # must be the label + self.on_texture_size(child, None) + + def on_texture_size(self, label, texture_size): + if self.autowidth: + # adapt the width to the label's width + self.width = max(0, sum([child.width for child in self.children])) + else: + # adapt the label's width + others = sum([child.width for child in self.children if child != label]) + label.width = self.width - others + label.height = self.height + label.text_size = (self.width - others, self.height) + + def on_tooltip(self, callee, text): + self.set_tooltip(text) + + + ## properties + + @property + def cfg(self): + return self.root.session.cfg + + + ## action triggering + + @classmethod + def single_shot(cls, root, *args, **kwargs): + #logger.info(f'action: {cls.__name__}, {args}, {kwargs}') + root.action_log.append(str(cls.__name__)) + return cls(root=root).apply(*args, **kwargs) + + def on_root(self, wx, root): + root.keys.bind(on_press=self.on_keyboard) + + def on_press(self): + self.selected_alpha = 1 + + def on_release(self): + if self.touch_trigger: + Animation(selected_alpha=0, d=.25, t='out_quad').start(self) + #logger.info(f'action: {type(self).__name__}') + self.root.action_log.append(str(type(self).__name__)) + self.apply() + + def on_keyboard(self, wx, evt): + if self.key_trigger and self.ktrigger(evt): + #logger.info(f'action: {type(self).__name__}') + self.root.action_log.append(str(type(self).__name__)) + self.apply() + # stop the event from further processing + return True + + + ## interfaces for subtypes + + def ktrigger(self, evt): + """Return True if the action should be triggered by keyboard event *evt*.""" + return False + + def apply(self, *args, **kwargs): + """Execute the action.""" + pass + +## EOF ## diff --git a/tagit/actions/browser.kv b/tagit/actions/browser.kv new file mode 100644 index 0000000..adcfbd6 --- /dev/null +++ b/tagit/actions/browser.kv @@ -0,0 +1,99 @@ +#:import resource_find kivy.resources.resource_find + +: + source: resource_find('atlas://browser/next_page') + tooltip: 'One page down' + +: + source: resource_find('atlas://browser/previous_page') + tooltip: 'One page up' + +: + source: resource_find('atlas://browser/scroll_up') + tooltip: 'One row up' + +: + source: resource_find('atlas://browser/scroll_down') + tooltip: 'One row down' + +: + source: resource_find('atlas://browser/jump_to_page') + tooltip: 'Jump to a specified page' + +: + source: resource_find('atlas://browser/zoom_in') + tooltip: 'Zoom in' + +: + source: resource_find('atlas://browser/zoom_out') + tooltip: 'Zoom out' + +: + source: resource_find('atlas://browser/jump_to_cursor') + tooltip: 'Jump to cursor' + +: + source: resource_find('atlas://browser/set_cursor') + tooltip: 'Set the cursor' + +: + source: resource_find('atlas://browser/cursor_first') + tooltip: 'Go to first image' + +: + source: resource_find('atlas://browser/cursor_last') + tooltip: 'Go to last image' + +: + source: resource_find('atlas://browser/cursor_up') + tooltip: 'Cursor up' + +: + source: resource_find('atlas://browser/cursor_down') + tooltip: 'Cursor down' + +: + source: resource_find('atlas://browser/cursor_left') + tooltip: 'Cursor left' + +: + source: resource_find('atlas://browser/cursor_right') + tooltip: 'Cursor right' + +: + source: resource_find('atlas://browser/select_all') + tooltip: 'Select all' + +: + source: resource_find('atlas://browser/select_none') + tooltip: 'Clear selection' + +: + source: resource_find('atlas://browser/select_invert') + tooltip: 'Invert selection' + +: + source: resource_find('atlas://browser/select_single') + tooltip: 'Select one' + +: + source: resource_find('atlas://browser/select_multi') + tooltip: 'Select many' + +: + source: resource_find('atlas://browser/select_add') + tooltip: 'Add to selection' + +: + source: resource_find('atlas://browser/select_sub') + tooltip: 'Remove from selection' + +: + source: resource_find('atlas://browser/select_range') + tooltip: 'Select range' + +