diff --git a/construct/api.py b/construct/api.py index 6c27ab9..232764c 100644 --- a/construct/api.py +++ b/construct/api.py @@ -6,14 +6,13 @@ import atexit import inspect import logging -from functools import wraps from logging.config import dictConfig # Local imports -from . import schemas -from .compat import Mapping, basestring +from . import migrations, schemas +from .compat import Mapping, basestring, wraps, zip_longest from .constants import DEFAULT_LOGGING -from .context import Context +from .context import Context, validate_context from .errors import ContextError from .events import EventManager from .extensions import ExtensionManager @@ -85,6 +84,7 @@ def __init__(self, name=None, **kwargs): self.extensions = ExtensionManager(self) self.context = Context() self.schemas = schemas + self.migrations = migrations self.io = IO(self) self.ui = UIManager(self) self._logging_dict = kwargs.pop('logging', None) @@ -179,7 +179,7 @@ def uninit(self): def get_context(self): '''Get a copy of the current context. - .. seealso:: :class:`construct.context.Context` + .. seealso:: :class:`construct.context.Context.copy` ''' return self.context.copy() @@ -191,7 +191,7 @@ def set_context(self, ctx): Set the active context permanently:: >>> new_ctx = api.get_context() - >>> new_ctx.project = 'A_PROJECT' + >>> new_ctx['project'] = 'A_PROJECT' >>> api.set_context(new_context) Temporarily set context:: @@ -231,13 +231,50 @@ def set_context_from_path(self, path): self.context = self.context_from_path(path) + def _context_from_obj(self, obj, data): + if obj['_type'] == 'project': + data['project'] = obj['name'] + project = self.io.get_project_by_id(obj['_id']) + project_path = self.io.get_path_to(project) + location, mount = self.get_mount_from_path(project_path) + data['location'] = location + data['mount'] = mount + elif obj['type'] == 'asset': + data['asset'] = obj['name'] + data['bin'] = obj['bin'] + project = self.io.get_project_by_id(obj['project_id']) + self._context_from_obj(project, data) + + def context_from_obj(self, obj, data=None): + '''Returns a Context instance from the specific data obj. + + Arguments: + obj (dict) - Project or asset data. + ''' + + data = {} + self._context_from_obj(obj, data) + + context = Context( + host=self.context['host'], + location=self.context['location'], + mount=self.context['mount'], + **data + ) + return context + + def validate_context(self, context): + '''Returns True if the context is valid.''' + + return validate_context(self, context) + def context_from_path(self, path): - ctx = Context(host=self.context.host) + ctx = Context(host=self.context['host']) path = unipath(path) if path.is_file(): - ctx.file = path.as_posix() + ctx['file'] = path.as_posix() location_mount = self.get_mount_from_path(path) if location_mount: @@ -252,6 +289,74 @@ def context_from_path(self, path): return ctx + def context_from_uri(self, uri): + '''Create a Context object from an uri. + + Examples: + + >>> api.context_from_uri('cons://local/projects/project') + {'location': 'local', 'mount': 'projects', 'project': 'project'} + + >>> api.context_from_uri('local/projects/project') + {'location': 'local', 'mount': 'projects', 'project': 'project'} + ''' + + # Split off uri_prefix + uri_prefix = None + if '://' in uri: + uri_prefix, uri = uri.split('://', 1) + uri_prefix += '://' + + uri_parts = uri.strip(' /\\').split('/') + uri_parts_map = [ + 'location', + 'mount', + 'project', + 'bin', + 'asset', + 'workspace', + 'task', + 'file', + ] + + context = Context( + host=self.context['host'], # Inject host from current context + ) + for key, value in zip_longest(uri_parts_map, uri_parts): + context[key] = value + return context + + def uri_from_context(self, context): + '''Create a Context object from an uri. + + Examples: + + >>> ctx = Context( + ... location='local', + ... mount='projects', + ... project='project' + ... ) + >>> api.uri_from_context(ctx) + 'cons://local/projects/project' + ''' + + uri_parts = [] + uri_parts_map = [ + 'location', + 'mount', + 'project', + 'bin', + 'asset', + 'task', + 'workspace', + 'file', + ] + for key in uri_parts_map: + value = context.get(key, None) + if value: + uri_parts.append(value) + return 'cons://' + '/'.join(uri_parts) + def set_mount(self, location, mount): self.update_context(location=location, mount=mount) @@ -264,11 +369,11 @@ def get_mount(self, location=None, mount=None): mount (str): Name of mount or context['mount'] ''' - location = location or self.context.location - mount = mount or self.context.mount + location = location or self.context['location'] + mount = mount or self.context['mount'] path = self.settings['locations'][location][mount] if isinstance(path, dict): - path = path[self.context.platform] + path = path[self.context['platform']] ensure_exists(path) return unipath(path) else: @@ -294,7 +399,7 @@ def get_locations(self): def host(self): '''Get the active Host Extension.''' - return self.extensions.get(self.context.host, None) + return self.extensions.get(self.context['host'], None) def define(self, event, doc): '''Define a new event @@ -395,10 +500,12 @@ def unextend(self, name): else: _log.debug(name + ' was not registered with api.extend.') - def show(self, data): + def show(self, data, *include_keys): '''Pretty print a dict or list of dicts.''' if isinstance(data, Mapping): + if include_keys: + data = {k: data[k] for k in include_keys if k in data} print(yaml_dump(dict(data)).decode('utf-8')) return elif isinstance(data, basestring): @@ -407,8 +514,8 @@ def show(self, data): try: for obj in data: + self.show(obj, *include_keys) print('') - print(yaml_dump(obj).decode('utf-8')) except: print('Can not format: %s' % data) diff --git a/construct/compat.py b/construct/compat.py index 8e0f56a..8e3b690 100644 --- a/construct/compat.py +++ b/construct/compat.py @@ -1,4 +1,12 @@ # -*- coding: utf-8 -*- +''' +Python2-3 Compatability +----------------------- +Rather than performing variable imports on a module by module basis, all +python compatability issues are handled here. This makes imports throughout +construct slightly neater than otherwise. +''' + # Third party imports import six @@ -9,9 +17,9 @@ from pathlib import Path try: - from collections.abc import Mapping + from collections.abc import Mapping, MutableMapping, MutableSequence, Sequence except ImportError: - from collections import Mapping + from collections import Mapping, MutableMapping, MutableSequence, Sequence if six.PY2: import functools @@ -22,9 +30,11 @@ def wraps(wrapped, assigned=_assignments, updated=_updates): assigned = set(members) & set(assigned) updated = set(members) & set(updated) return functools.wraps(wrapped, assigned, updated) + + from itertools import izip_longest as zip_longest else: from functools import wraps - + from itertools import zip_longest # Instead of python-future basestring = six.string_types diff --git a/construct/constants.py b/construct/constants.py index 14a2ce3..77da7c6 100644 --- a/construct/constants.py +++ b/construct/constants.py @@ -32,7 +32,7 @@ } } DEFAULT_TREE = { - 'asset': '{mount}/{project}/{bin}/{asset_type}/{asset}', + 'asset': '{mount}/{project}/{bin}/{asset}', 'workspace': '{mount}/{project}/{bin}/{asset}/work/{task}/{host}', 'publish': '{mount}/{project}/{bin}/{asset}/publish/{item}/v{version:0>3d}', 'review': '{mount}/{project}/{bin}/{asset}/review/{task}/{host}', diff --git a/construct/context.py b/construct/context.py index 3c787b0..b1a41f8 100644 --- a/construct/context.py +++ b/construct/context.py @@ -11,9 +11,10 @@ # Local imports from .compat import basestring from .constants import DEFAULT_HOST, PLATFORM +from .errors import ContextError -__all__ = ['Context'] +__all__ = ['Context', 'validate_context'] def encode(obj): @@ -29,11 +30,9 @@ def decode(obj): class Context(dict): - '''Represents a state used to interact with Construct. The Construct object - provides both item and attribute access. + '''Represents a state used to interact with Construct. >>> ctx = Context() - >>> ctx.project = {'name': 'my_project'} >>> ctx['project'] == ctx.project Context objects can be loaded from and stored in environment variables @@ -65,10 +64,9 @@ class Context(dict): | file | Path to current working file | +-----------+------------------------------------------------------+ - The above keys default to None so when checking context it can be - convenient to use attribute access. + The above keys default to None, convenient for quickly checking context. - >>> if ctx.project and ctx.task: + >>> if ctx['project'] and ctx['task']: ... # Do something that depends on a project and task ''' @@ -95,18 +93,37 @@ def __init__(self, **kwargs): self.update(**self._defaults) self.update(**kwargs) - def __getattr__(self, name): - try: - return self[name] - except KeyError: - raise AttributeError(name) + def trim(self, key): + '''Returns a new context with context beyond key set to None. - def __setattr__(self, name, value): - self[name] = value - - def copy(self): + Example: + >>> c = Context(location='location', mount='mount', + ... project='project', bin='bin', asset='asset') + >>> c2 = c.trim('project') + >>> assert c2['project'] == 'project' + >>> assert c2['bin'] is None + >>> assert c2['asset'] is None + ''' + context = Context() + include = [ + 'host', + 'location', + 'mount', + 'project', + 'bin', + 'asset', + 'task' + 'workspace', + ] + include = include[:include.index(key) + 1] + context.update(**{k: self[k] for k in include}) + return context + + def set(self, key, value): + self[key] = value + + def copy(self, *include_keys): '''Copy this context''' - return self.__class__(**copy.deepcopy(self)) def load(self, env=None, **kwargs): @@ -160,3 +177,63 @@ def clear_env(cls, env=None): for key in cls._keys: env_key = ('construct_' + key).upper() os.environ.pop(env_key, None) + + +def validate_context(api, context): + '''Return True if context is Valid.''' + + # validate context + locations = api.get_locations() + if context['location'] and context['location'] not in locations: + raise ContextError('Location does not exist: ' + context['location']) + + mounts = locations[context['location']] + if context['mount'] and context['mount'] not in mounts: + raise ContextError('Mount does not exist: ' + context['mount']) + + io_context = { + 'location': context['location'], + 'mount': context['mount'], + } + + project = None + if context['project']: + with api.set_context(io_context): + project = api.io.get_project(context['project']) + if not project: + raise ContextError('Project does not exist: ' + context['project']) + path = api.io.get_path_to(project) + location, mount = api.get_mount_from_path(path) + context['mount'] = mount + + bin = None + if context['bin']: + conditions = [ + not project, + context['bin'] not in project['bins'], + ] + if any(conditions): + raise ContextError('Bin does not exist: ' + context['bin']) + bin = context['bin'] + + asset = None + if context['asset']: + conditions = [ + not project, + not bin, + context['asset'] not in project['assets'], + ] + if any(conditions): + raise ContextError('Asset does not exist: ' + context['asset']) + asset = context['asset'] + + task = None + if context['task']: + conditions = [ + not project, + context['task'] not in project['task_types'] + ] + if any(conditions): + raise ContextError('Task is invalid: ' + context['task']) + + return context diff --git a/construct/ext/cache.py b/construct/ext/cache.py index c689aed..e22ebcb 100644 --- a/construct/ext/cache.py +++ b/construct/ext/cache.py @@ -37,6 +37,18 @@ def unload(self, api): def __contains__(self, key): return self.cache.__contains__(key) + def to_dict(self): + return self.cache.to_dict() + + def keys(self): + return self.cache.keys() + + def values(self): + return self.cache.values() + + def items(self): + return self.cache.items() + def get(self, key, default=missing): return self.cache.get(key, default) @@ -88,6 +100,18 @@ def __contains__(self, key): def _file_for(self, key): return self.cache_dir / key + def to_dict(self): + return {k: self.get(k) for k in self.keys()} + + def keys(self): + return [c.name for c in self.cache_dir.iterdir() if c.is_file()] + + def values(self): + return [self.get(k) for k in self.keys()] + + def items(self): + return [(k, self.get(k)) for k in self.keys()] + def get(self, key, default=missing): key_file = self._file_for(key) if not key_file.exists(): diff --git a/construct/ext/software.py b/construct/ext/software.py index 59cb06b..f5ba5f8 100644 --- a/construct/ext/software.py +++ b/construct/ext/software.py @@ -27,7 +27,7 @@ class Software(Extension): label = 'Software Extension' def is_available(self, ctx): - return ctx.project + return ctx['project'] def load(self, api): api.settings.add_section('software', 'software') @@ -91,7 +91,8 @@ def get(self, project=None, file=None): ''' ext = None - project = project or self.api.context.project + ctx = self.api.get_context() + project = project or ctx['project'] if file: ext = os.path.splitext(file)[-1] @@ -179,6 +180,7 @@ def launch(self, name, *args, **kwargs): '%s has no registered ext.' % software['host'] ) + self.api.send('before_launch', self.api, software, env, ctx) _log.debug('Launching %s' % name.title()) _run(cmd, env=env) @@ -199,7 +201,7 @@ def open_with(self, name, file, *args, **kwargs): raise OSError('Can not open %s with %s' % (name, file)) ctx = kwargs.pop('context', self.api.context).copy() - ctx.file = file + ctx['file'] = file args = list(args or software['args']) cmd = [cmd] + args + [file] env = _get_software_env(software, ctx, self.api.path) @@ -215,12 +217,13 @@ def open_with(self, name, file, *args, **kwargs): '%s has no registered ext.' % software['host'] ) + self.api.send('before_launch', self.api, software, env, ctx) _log.debug('Launching %s' % name.title()) _run(cmd, env=env) def _get_command(software): - '''Get the platform specific command used to execute a piece of software.''' + '''Get the platform specific command used to launch the software.''' cmd = software['cmd'].get(platform, None) if isinstance(cmd, basestring): diff --git a/construct/io/__init__.py b/construct/io/__init__.py index 9d9a14b..9f918a4 100644 --- a/construct/io/__init__.py +++ b/construct/io/__init__.py @@ -35,20 +35,20 @@ def load(self): '(api, project): Set after a project is updated.' ) self.api.define( - 'before_new_folder', - '(api, folder): Sent before a folder is created.' + 'before_new_bin', + '(api, bin): Sent before a bin is created.' ) self.api.define( - 'after_new_folder', - '(api, folder): Sent after a folder is created.' + 'after_new_bin', + '(api, bin): Sent after a bin is created.' ) self.api.define( - 'before_update_folder', - '(api, folder, update): Sent before a folder is updated.' + 'before_update_bin', + '(api, bin, update): Sent before a bin is updated.' ) self.api.define( - 'after_update_folder', - '(api, folder): Set after a folder is updated.' + 'after_update_bin', + '(api, bin): Set after a bin is updated.' ) def unload(self): @@ -75,7 +75,8 @@ def get_projects(self, location=None, mount=None): Generator or cursor yielding projects ''' - location = location or self.api.context.location + location = location or self.api.context['location'] + mount = mount or self.api.context['mount'] return self.fsfs.get_projects(location, mount) @@ -94,7 +95,8 @@ def get_project_by_id(self, _id, location=None, mount=None): Project dict or None ''' - location = location or self.api.context.location + location = location or self.api.context['location'] + mount = mount or self.api.context['mount'] return self.fsfs.get_project_by_id(_id, location, mount) @@ -113,7 +115,8 @@ def get_project(self, name, location=None, mount=None): Project dict or None ''' - location = location or self.api.context.location + location = location or self.api.context['location'] + mount = mount or self.api.context['mount'] return self.fsfs.get_project(name, location, mount) diff --git a/construct/io/fsfs.py b/construct/io/fsfs.py index 7a47dad..041591e 100644 --- a/construct/io/fsfs.py +++ b/construct/io/fsfs.py @@ -7,8 +7,10 @@ from __future__ import absolute_import # Standard library imports +import errno import logging import shutil +from copy import deepcopy # Third party imports from bson.objectid import ObjectId @@ -19,9 +21,51 @@ _log = logging.getLogger(__name__) +missing = object() search_pattern = '*/.data/uuid_*' data_dir = '.data' data_file = 'data' +IGNORE_ERRNO = ( + errno.EACCES, + errno.ENOENT, + errno.EIO, + errno.EPERM, + 59, # WinError network access + errno.EINVAL, # WinError network access +) + + +class FileCache(object): + '''Returns data for file only when mtime is unchanged.''' + + def __init__(self): + self._cache = {} + self._mtimes = {} + + def pop(self, file): + self._mtimes.pop(file, None) + return self._cache.pop(file, None) + + def set(self, file, data): + self._cache[file] = deepcopy(data) + self._mtimes[file] = file.stat().st_mtime + + def get(self, file, default=missing): + if file not in self._cache: + if default is missing: + raise KeyError('File not in cache: ' + file.stem) + return default + + if self._mtimes[file] < file.stat().st_mtime: + self.pop(file) + if default is missing: + raise KeyError('File out of date: ' + file.stem) + return default + + return deepcopy(self._cache[file]) + + +cache = FileCache() def search_by_id(path, _id, max_depth=10): @@ -64,10 +108,23 @@ def search_by_name(path, name, max_depth=10): return best +def safe_iterdir(path): + + contents = path.iterdir() + while True: + try: + yield next(contents) + except (OSError, WindowsError) as e: + if e.errno not in IGNORE_ERRNO: + _log.exception('Unrecognized error in safe_iterdir.') + except StopIteration: + return + + def search(path, max_depth=10): '''Yields directories with metadata.''' - roots = list(path.iterdir()) + roots = list(safe_iterdir(path)) level = 0 while roots and level < max_depth: @@ -75,18 +132,32 @@ def search(path, max_depth=10): next_roots = [] for root in roots: - if root.is_file() or root.name == '.data': continue if exists(root): yield root - next_roots.extend(list(path.iterdir())) + next_roots.extend(list(safe_iterdir(root))) level += 1 roots = next_roots + return + + +def parents(path, tag=None): + + for parent in path.parents: + if exists(parent): + if tag and tag in get_tags(parent): + yield parent + + +def parent(path, tag=None): + for parent in parents(path, tag): + return parent + def exists(path): '''Check if a path is already initialized.''' @@ -133,17 +204,19 @@ def read(path, *keys): ''' path = Path(path) - file = path / data_dir / data_file - if not file.exists(): raise OSError('Data file does not exist: ' + file.as_posix()) - raw_data = file.read_text(encoding='utf-8') - if not raw_data: - return {} + try: + data = cache.get(file) + except KeyError: + raw_data = file.read_text(encoding='utf-8') + if not raw_data: + return {} - data = yaml_load(raw_data) + data = yaml_load(raw_data) + cache.set(file, data) if not keys: return data @@ -167,11 +240,9 @@ def write(path, replace=False, **data): ''' path = Path(path) - init(path) file = path / data_dir / data_file - if not replace: new_data = read(path) update_dict(new_data, data) @@ -180,6 +251,9 @@ def write(path, replace=False, **data): file.write_bytes(yaml_dump(new_data)) + cache.pop(file) + return new_data + def set_id(path, value): '''Set the id for the directory. @@ -237,7 +311,7 @@ def delete(path, remove_root=False): path_data_dir = path / data_dir if path_data_dir.exists(): - path_data_dir.unlink() + shutil.rmtree(path_data_dir.as_posix()) if remove_root: shutil.rmtree(path.as_posix()) diff --git a/construct/io/fsfslayer.py b/construct/io/fsfslayer.py index 979d04b..c78e750 100644 --- a/construct/io/fsfslayer.py +++ b/construct/io/fsfslayer.py @@ -14,6 +14,16 @@ _log = logging.getLogger(__name__) +def _read_project(project): + '''Packs project data with minimum data necessary for construct 0.2+''' + + data = fsfs.read(project) + data.setdefault('name', project.name) + data.setdefault('_id', fsfs.get_id(project)) + data.setdefault('_type', 'project') + return data + + class FsfsLayer(IOLayer): def __init__(self, api): @@ -33,30 +43,30 @@ def _get_projects(self, location, mount=None): yield entry def get_projects(self, location, mount=None): - for entry in self._get_projects(location, mount): - yield fsfs.read(entry) + for project in self._get_projects(location, mount): + yield _read_project(project) def get_project_by_id(self, _id, location=None, mount=None): - location = self.api.context.location + location = self.api.context['location'] for project in self._get_projects(location, mount): if fsfs.get_id(project) == _id: - return fsfs.read(project) + return _read_project(project) def get_project(self, name, location, mount=None): if mount: root = self.api.get_mount(location, mount) - for entry in fsfs.search(root, max_depth=1): - if entry.name == name: - return fsfs.read(entry) + for project in fsfs.search(root, max_depth=1): + if project.name == name: + return _read_project(project) return for mount in self.settings['locations'][location].keys(): root = self.api.get_mount(location, mount) - for entry in fsfs.search(root, max_depth=1): - if entry.name == name: - return fsfs.read(entry) + for project in fsfs.search(root, max_depth=1): + if project.name == name: + return _read_project(project) def new_project(self, name, location, mount, data): path = self.api.get_mount(location, mount) / name @@ -87,32 +97,44 @@ def delete_project(self, project): fsfs.delete(path) def get_assets(self, project, bin=None, asset_type=None, group=None): - my_location = self.api.context.location - project = self.get_project_by_id(project['_id'], my_location) + my_location = self.api.context['location'] + + for path in self._get_projects(my_location): + if fsfs.get_id(path) == project['_id']: + project_path = path + + project = fsfs.read(project_path) assets = project['assets'] for asset in assets.values(): asset['project_id'] = project['_id'] - asset_path = self.get_path_to(asset) - asset = fsfs.read(asset_path) + asset['_type'] = 'asset' + if bin and asset['bin'] != bin: continue if asset_type and asset['asset_type'] != asset_type: continue if group and asset['group'] != group: continue + + asset_path = self.get_path_to(asset, project_path) + asset = fsfs.read(asset_path) yield asset def get_asset(self, project, name): - potential_names = [] - for asset in self.get_assets(project): - if name in asset['name']: - potential_names.append(name) - if asset['name'] == name: - return asset + my_location = self.api.context['location'] + + for path in self._get_projects(my_location): + if fsfs.get_id(path) == project['_id']: + project_path = path + + project = fsfs.read(project_path) - if potential_names: - return min(potential_names, key=len) + asset = project['assets'][name] + asset['project_id'] = project['_id'] + asset['_type'] = 'asset' + asset_path = self.get_path_to(asset, project_path) + return fsfs.read(asset_path) def new_asset(self, project, data): path = self.get_path_to(data) @@ -157,10 +179,10 @@ def new_workfile(self, asset, name, identifier, task, file_type, data): def new_publish(self, asset, name, identifier, task, file_type, data): return NotImplemented - def get_path_to(self, entity): + def get_path_to(self, entity, project_path=None): '''Get a file system path to the provided entity.''' - my_location = self.api.context.location + my_location = self.api.context['location'] if entity['_type'] == 'project': @@ -182,10 +204,11 @@ def get_path_to(self, entity): if entity['_type'] == 'asset': - for project in self._get_projects(my_location): - if fsfs.get_id(project) == entity['project_id']: - project_path = project - break + if project_path is None: + for project in self._get_projects(my_location): + if fsfs.get_id(project) == entity['project_id']: + project_path = project + break location, mount = self.api.get_mount_from_path(project_path) mount = self.api.get_mount(location, mount) diff --git a/construct/migrations/__init__.py b/construct/migrations/__init__.py index 4083deb..796aaf4 100644 --- a/construct/migrations/__init__.py +++ b/construct/migrations/__init__.py @@ -9,6 +9,7 @@ # Local imports from ..compat import Path, basestring +from . import utils _log = logging.getLogger(__name__) @@ -112,6 +113,10 @@ def initial_migration(api, project): InitialMigration(api, project).forward() +def requires_initial_migration(project): + return 'schema_version' not in project + + def get_migrations(migrations_dir=None): import sys relative_dir = Path(__file__).parent diff --git a/construct/migrations/initial.py b/construct/migrations/initial.py index 3b5a421..c2e7d98 100644 --- a/construct/migrations/initial.py +++ b/construct/migrations/initial.py @@ -5,11 +5,9 @@ # Standard library imports import logging -# Third party imports -import fsfs - # Local imports from ..compat import Path +from ..io import fsfs from . import Migration @@ -23,8 +21,18 @@ class InitialMigration(object): projects are migrated over. ''' + tree = { + 'asset': '{mount}/{project}//{bin}/{group}/{asset}', + 'workspace': '{mount}/{project}//{bin}/{group}/{asset}/{task}/work/{host}', + 'publish': '{mount}/{project}//{bin}/{group}/{asset}/{task}/publish/{task_short}_{name}/v{version:0>3d}', + 'review': '{mount}/{project}/review/{task}/{bin}/{asset}', + 'render': '{mount}/{project}//renders/{host}/{bin}/{asset}', + 'file': '{task_short}_{name}_v{version:0>3d}.{ext}', + 'file_sequence': '{task_short}_{name}_v{version:0>3d}.{frame}.{ext}', + } project_tags = set(['project']) - folder_tags = set(['collection', 'asset_type', 'sequence']) + bin_tags = set(['collection']) + group_tags = set(['asset_type', 'sequence']) asset_tags = set(['asset', 'shot']) task_tags = set(['task']) workspace_tags = set(['workspace']) @@ -34,124 +42,131 @@ def __init__(self, api, path): self.path = path def forward(self): - query = fsfs.search( - root=str(self.path), - depth=3, - levels=10, - skip_root=False - ) - for entry in query: - self.migrate(entry) + # Migrate root - should be project + self.migrate(self.path) + + # Migrate bins and assets + query = fsfs.search(self.path, max_depth=10) + for path in query: + self.migrate(path) def backward(self): pass - def migrate(self, entry): - tags = set(entry.tags) + def migrate(self, path): + tags = set(fsfs.get_tags(path)) if tags & self.project_tags: - self.migrate_project(entry) - elif tags & self.folder_tags: - self.migrate_folder(entry) + self.migrate_project(path) + elif tags & self.bin_tags: + self.migrate_bin(path) elif tags & self.asset_tags: - self.migrate_asset(entry) + self.migrate_asset(path) elif tags & self.task_tags: - self.migrate_task(entry) + self.migrate_task(path) elif tags & self.workspace_tags: - self.migrate_workspace(entry) + self.migrate_workspace(path) else: - print('WARNING: could not migrate %s' % entry) + _log.debug('Could not migrate %s' % path) - def migrate_project(self, project): - _log.debug('Found project: %s' % project) - - data = project.read() + def migrate_project(self, path): + _log.debug('Found project: %s' % path) + data = fsfs.read(path) data.update( - name=project.name, - locations={}, + name=path.name, + tree=self.tree, ) - location_mount = self.api.get_mount_from_path(project.path) - if location_mount: - location, mount = location_mount - data['locations'][location] = mount - # Fill data with defaults from schema data = self.api.schemas.validate('project', data) # Update tree - first_child = project.children().one() + first_child = next(fsfs.search(path)) if first_child: - folders_path = Path(first_child.path).parent + bin_path = first_child.parent try: - folders = str(folders_path.relative_to(project.path)) + bin_root = str(bin_path.relative_to(path)) except ValueError: - folders = '' - data['tree']['folders'] = folders - - project.tag('project') - project.uuid = data['_id'] - project.write(**data) - - def migrate_folder(self, folder): - project = folder.parents().tags('project').one() - parent = folder.parent() - _log.debug('Found folder: %s/%s' % (parent.name, folder.name)) - - data = folder.read() - data.update( - name=folder.name, - project_id=project.uuid, - parent_id=parent.uuid, - ) - data = self.api.schemas.validate('folder', data) - - folder.tag('folder') - folder.uuid = data['_id'] - folder.write(**data) - - def migrate_asset(self, asset): - project = asset.parents().tags('project').one() - parent = asset.parent() - _log.debug('Found asset: %s/%s' % (parent.name, asset.name)) - - if 'shot' in asset.tags: + bin_root = None + + for k, v in list(data['tree'].items()): + if '' in v: + if bin_root: + data['tree'][k] = v.replace('', bin_root) + else: + data['tree'][k] = v.replace('/', '') + + fsfs.init(path) + fsfs.tag(path, 'project') + fsfs.set_id(path, data['_id']) + fsfs.write(path, replace=True, **data) + + def migrate_bin(self, path): + project_path = fsfs.parent(path, tag='project') + _log.debug('Found bin: %s/%s' % (project_path.name, path.name)) + + project = fsfs.read(project_path) + bins = project.get('bins', {}) + + if path.name in bins: + return + + bin_data = { + 'name': path.name, + 'icon': '', + 'order': len(bins), + } + bins[bin_data['name']] = bin_data + + project['bins'] = bins + fsfs.write(project_path, **project) + + fsfs.init(path) + fsfs.tag(path, 'bin') + fsfs.write(path, **bin_data) + + def migrate_asset(self, path): + project_path = fsfs.parent(path, tag='project') + _log.debug('Found asset: %s/%s' % (project_path.name, path.name)) + group_path = path.parent + bin_path = path.parent.parent + + project = fsfs.read(project_path) + tags = fsfs.get_tags(path) + data = fsfs.read(path) + + if 'shot' in tags: asset_type = 'shot' else: asset_type = 'asset' - data = asset.read() data.update( - name=asset.name, - project_id=project.uuid, - parent_id=parent.uuid, + project_id=project['_id'], + name=path.name, asset_type=asset_type, + group=group_path.name, + bin=bin_path.name, ) + + # Fill data with defaults from schema data = self.api.schemas.validate('asset', data) - asset.tag('asset') - asset.uuid = data['_id'] - asset.write(**data) + fsfs.init(path) + fsfs.tag(path, 'asset') + fsfs.write(path, **data) - def migrate_task(self, task): - project = task.parents().tags('project').one() - parent = task.parent() - _log.debug('Found task: %s/%s' % (parent.name, task.name)) + project['assets'][data['name']] = { + '_id': data['_id'], + 'name': data['name'], + 'bin': data['bin'], + 'asset_type': data['asset_type'], + 'group': data['group'], + } + fsfs.write(project_path, **project) - data = task.read() - data.update( - name=task.name, - project_id=project.uuid, - parent_id=parent.uuid, - ) - data = self.api.schemas.validate('task', data) - - task.tag('task') - task.uuid = data['_id'] - task.write(**data) + def migrate_task(self, task): + pass def migrate_workspace(self, workspace): - project = workspace.parents().tags('project').one() - parent = workspace.parent() - _log.debug('Found workspace: %s/%s' % (parent.name, workspace.name)) + pass diff --git a/construct/migrations/utils.py b/construct/migrations/utils.py new file mode 100644 index 0000000..173b04a --- /dev/null +++ b/construct/migrations/utils.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- + +# Local imports +from ..io import fsfs + + +def create_old_project(where, pmnt='3D'): + '''Creates a Construct 0.1.x style project.''' + + def new_asset(p, col, typ, asset): + return [ + (p / pmnt / col, 'collection'), + (p / pmnt / col / typ, 'asset_type'), + (p / pmnt / col / typ / asset, 'asset'), + (p / pmnt / col / typ / asset / 'model', 'task'), + (p / pmnt / col / typ / asset / 'model/work/maya', 'workspace'), + (p / pmnt / col / typ / asset / 'rig', 'task'), + (p / pmnt / col / typ / asset / 'rig/work/maya', 'workspace'), + (p / pmnt / col / typ / asset / 'shade', 'task'), + (p / pmnt / col / typ / asset / 'shade/work/maya', 'workspace'), + (p / pmnt / col / typ / asset / 'light', 'task'), + (p / pmnt / col / typ / asset / 'light/work/maya', 'workspace'), + (p / pmnt / col / typ / asset / 'comp', 'task'), + (p / pmnt / col / typ / asset / 'comp/work/maya', 'workspace'), + ] + + def new_shot(p, col, seq, shot): + return [ + (p / pmnt / col, 'collection'), + (p / pmnt / col / seq, 'sequence'), + (p / pmnt / col / seq / shot, 'shot'), + (p / pmnt / col / seq / shot / 'anim', 'task'), + (p / pmnt / col / seq / shot / 'anim/work/maya', 'workspace'), + (p / pmnt / col / seq / shot / 'light', 'task'), + (p / pmnt / col / seq / shot / 'light/work/maya', 'workspace'), + (p / pmnt / col / seq / shot / 'fx', 'task'), + (p / pmnt / col / seq / shot / 'fx/work/maya', 'workspace'), + (p / pmnt / col / seq / shot / 'comp', 'task'), + (p / pmnt / col / seq / shot / 'comp/work/maya', 'workspace'), + ] + + entries = [(where, 'project')] + entries.extend(new_asset(where, 'assets', 'prop', 'prop_01')) + entries.extend(new_asset(where, 'assets', 'product', 'product_01')) + entries.extend(new_asset(where, 'assets', 'character', 'char_01')) + entries.extend(new_shot(where, 'shots', 'seq_01', 'seq_01_010')) + entries.extend(new_shot(where, 'shots', 'seq_01', 'seq_01_020')) + entries.extend(new_shot(where, 'shots', 'seq_01', 'seq_01_030')) + entries.extend(new_shot(where, 'users', 'user_01', 'user_01_010')) + entries.extend(new_shot(where, 'users', 'user_01', 'user_01_020')) + entries.extend(new_shot(where, 'users', 'user_01', 'user_01_030')) + + for path, tag in entries: + fsfs.tag(path, tag) diff --git a/construct/ui/app/__init__.py b/construct/ui/app/__init__.py new file mode 100644 index 0000000..d53dc15 --- /dev/null +++ b/construct/ui/app/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +''' +isort:skip_file +''' + +# Local imports +from .__main__ import main +from .app import App diff --git a/construct/ui/app/__main__.py b/construct/ui/app/__main__.py new file mode 100644 index 0000000..c4cba5b --- /dev/null +++ b/construct/ui/app/__main__.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import + +# Third party imports +from Qt import QtWidgets + +# Local imports +import construct +from construct.ui.eventloop import get_event_loop +from construct.ui.theme import theme + + +def main(): + + api = construct.API() + + event_loop = get_event_loop() + + app = api.ui.launcher() + app.show() + + if QtWidgets.QSystemTrayIcon.isSystemTrayAvailable(): + + def on_tray_activated(reason): + if reason == QtWidgets.QSystemTrayIcon.Context: + return + app.setVisible(not app.isVisible()) + + tray = QtWidgets.QSystemTrayIcon(parent=app) + tray.setIcon(theme.icon('icons/construct.svg')) + tray.activated.connect(on_tray_activated) + tray_menu = QtWidgets.QMenu(parent=app) + tray_menu.addAction( + theme.icon('close', parent=app), + 'Close', + event_loop.quit + ) + tray.setContextMenu(tray_menu) + tray.show() + + event_loop.start() + + +if __name__ == '__main__': + main() diff --git a/construct/ui/app/app.py b/construct/ui/app/app.py new file mode 100644 index 0000000..dc598ba --- /dev/null +++ b/construct/ui/app/app.py @@ -0,0 +1,381 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import + +# Standard library imports +from collections import deque +from functools import partial + +# Third party imports +from Qt import QtCore, QtWidgets + +# Local imports +from ...context import Context +from .. import models +from ..dialogs import BookmarksDialog +from ..layouts import HBarLayout, VBarLayout +from ..scale import px +from ..state import State +from ..theme import theme +from ..widgets import Frameless, Header, HLine, IconButton, Navigation, Sidebar + + +class App(Frameless, QtWidgets.QDialog): + + css_id = '' + css_properties = { + 'theme': 'surface', + 'border': True, + 'windowTitle': 'Construct', + 'windowIcon': theme.resources.get('icons/construct.svg'), + } + + def __init__(self, api, context=None, uri=None, **kwargs): + super(App, self).__init__(**kwargs) + + # Set App state + if uri and not context: + context = api.context_from_uri(uri) + else: + context = context or api.get_context() + uri = api.uri_from_context(context) + + self.state = State( + api=api, + context=context, + uri=uri, + bookmarks=api.user_cache.get('bookmarks', []), + crumb_item=None, + history=deque(api.user_cache.get('history', []), maxlen=20), + tree_model=None, + selection=[], + ) + self.state['context'].changed.connect(self._on_ctx_changed) + self.state['uri'].changed.connect(self._on_uri_changed) + self.state['crumb_item'].changed.connect(self._on_crumb_item_changed) + self.state['bookmarks'].changed.connect(self._refresh_bookmark_button) + self.state['selection'].changed.connect(self._on_selection_changed) + + # Create widgets + self.header = Header('Launcher', parent=self) + self.header.close_button.clicked.connect(self.hide) + + self.navigation = Navigation(parent=self) + self.navigation.menu_button.clicked.connect(self._show_main_menu) + self.navigation.home_button.clicked.connect(self._on_home_clicked) + self.navigation.uri_changed.connect(self._on_uri_changed) + self.navigation.bookmark_button.clicked.connect(self._show_bookmarks) + + self.sidebar = Sidebar(self.state, parent=self) + self.body = QtWidgets.QWidget() + + self.splitter = QtWidgets.QSplitter(parent=self) + self.splitter.addWidget(self.sidebar) + self.splitter.addWidget(self.body) + self.splitter.setOrientation(QtCore.Qt.Horizontal) + self.splitter.setSizePolicy( + QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Expanding, + ) + self.splitter.setStretchFactor(0, 0) + self.splitter.setStretchFactor(1, 1) + + # Layout widgets + self.layout = VBarLayout(parent=self) + self.layout.setContentsMargins(*px(1, 1, 1, 1)) + self.layout.setSpacing(0) + self.layout.top.setSpacing(0) + self.layout.top.addWidget(self.header) + self.layout.top.addWidget(self.navigation) + self.layout.top.addWidget(HLine(self)) + self.layout.center.addWidget(self.splitter) + self.setLayout(self.layout) + + # Window Attributes + self.setWindowFlags( + self.windowFlags() | + QtCore.Qt.Window + ) + self.setMinimumSize(*px(600, 600)) + self.resize(*px(800, 600)) + self.setFocusPolicy(QtCore.Qt.StrongFocus) + self.setFocus() + + # Apply theme + theme.apply(self) + + # Update UI from initial state + self._build_crumbs(self.state['context'].copy()) + self._refresh_bookmark_button(self.state['uri'].get()) + + api.register_menu('crumb', self._on_crumb_menu_requested) + + def _show_main_menu(self): + api = self.state['api'].get() + menu = QtWidgets.QMenu(parent=self) + menu.addAction('Toggle Tree') + menu.addAction('Settings') + menu = api.ui.request_menu( + identifier='main', + context=self.state['context'].copy(), + menu=menu, + ) + button = self.navigation.menu_button + pos = button.mapToGlobal(button.rect().bottomLeft()) + menu.popup(pos) + + def _on_home_clicked(self): + api = self.state['api'].get() + context = Context( + host=api.context['host'] + ) + self.state.set('context', context) + + with self.state.signals_blocked(): + self.state.set('uri', '') + + def _on_uri_changed(self, uri): + api = self.state['api'].get() + context = api.context_from_uri(uri) + + if api.validate_context(context): + self.state.set('context', context) + + def _on_ctx_changed(self, context): + api = self.state['api'].get() + + with self.state.signals_blocked(): + # Setting uri state will update context: see _on_uri_changed + # By blocking signals here we prevent a loop + uri = api.uri_from_context(context) + self.state.set('uri', uri) + + self._refresh_bookmark_button(uri) + self._refresh_crumbs(context) + self.state['history'].appendleft((uri, dict(context))) + + def _refresh_bookmark_button(self, *args): + uri = self.state['uri'].get() + is_bookmarked = False + for bookmark in self.state['bookmarks'].get(): + if uri == bookmark['uri']: + is_bookmarked = True + + self.navigation.bookmark_button.set_icon( + ('bookmark_outline', 'bookmark')[is_bookmarked] + ) + + def _show_bookmarks(self, value): + dialog = BookmarksDialog(self.state, parent=self) + dialog.finished.connect( + lambda x: self.navigation.bookmark_button.setChecked(False) + ) + dialog.show() + dialog.setFocus() + + # Reposition dialog + button = self.navigation.bookmark_button + anchor = button.mapToGlobal(button.rect().bottomRight()) + dialog_anchor = dialog.mapToGlobal(dialog.rect().topRight()) + dialog_source = dialog.mapToGlobal(dialog.rect().topLeft()) + dialog.move( + dialog_source + (anchor - dialog_anchor) + QtCore.QPoint(0, 1) + ) + + def _on_crumb_item_changed(self, value): + for crumb in self.navigation.crumbs.iter(): + if crumb.label.text() == value: + if not crumb.arrow.isHidden(): + crumb.arrow.setFocus() + else: + crumb.label.setFocus() + + def _build_crumb_menu(self, crumb, key): + api = self.state['api'].get() + context = self.state['context'].copy() + crumb.menu.clear() + menu_items = [] + if key == 'home': + locations = api.get_locations() + menu_context = Context(host=context['host']) + for location in locations.keys(): + item_context = Context( + host=context['host'], + location=location, + ) + menu_items.append((location, item_context)) + elif key == 'location': + mounts = api.get_locations()[context['location']] + menu_context = Context( + host=context['host'], + location=context['location'], + ) + for mount in mounts.keys(): + item_context = Context( + host=context['host'], + location=context['location'], + mount=mount, + ) + menu_items.append((mount, item_context)) + elif key == 'mount': + with api.set_context(context): + projects = api.io.get_projects() + menu_context = Context( + host=context['host'], + location=context['location'], + mount=context['mount'], + ) + menu_items = [] + for project in projects: + item_context = Context( + host=context['host'], + location=context['location'], + mount=context['mount'], + project=project['name'], + ) + menu_items.append((project['name'], item_context)) + elif key == 'project': + with api.set_context(context): + project = api.io.get_project( + context['project'], + ) + menu_context = Context( + host=context['host'], + location=context['location'], + mount=context['mount'], + project=context['project'], + ) + menu_items = [] + for bin in project['bins'].values(): + item_context = Context( + host=context['host'], + location=context['location'], + mount=context['mount'], + project=context['project'], + bin=bin['name'], + ) + menu_items.append((bin['name'], item_context)) + elif key == 'bin': + with api.set_context(context): + project = api.io.get_project( + context['project'], + ) + menu_context = Context( + host=context['host'], + location=context['location'], + mount=context['mount'], + project=context['project'], + bin=context['bin'], + ) + menu_items = [] + for asset in project['assets'].values(): + if asset['bin'] != context['bin']: + continue + item_context = Context( + host=context['host'], + location=context['location'], + mount=context['mount'], + project=context['project'], + bin=asset['bin'], + asset=asset['name'], + ) + menu_items.append((asset['name'], item_context)) + elif key == 'asset': + menu_context = Context( + host=context['host'], + location=context['location'], + mount=context['mount'], + project=context['project'], + bin=context['bin'], + asset=context['asset'], + ) + + api.ui.request_menu( + identifier='crumb', + context=menu_context, + menu=crumb.menu + ) + crumb.menu.addSeparator() + + for item_label, item_context in menu_items: + callback = partial( + self.state.update, + context=item_context, + crumb_item=item_label, + ) + crumb.menu.addAction(item_label, callback) + + def _on_crumb_menu_requested(self, menu, ctx): + if ctx['bin']: + menu.addAction('New Asset') + elif ctx['project']: + menu.addAction('New Bin') + elif ctx['mount']: + menu.addAction('New Project') + elif ctx['location']: + menu.addAction('New Mount') + else: + menu.addAction('New Location') + + def _on_crumb_clicked(self, key): + context = self.state['context'].copy() + crumb_context = context.trim(key) + self.state.update( + context=crumb_context, + crumb_item=crumb_context[key], + ) + + def _refresh_crumbs(self, context): + api = self.state['api'].get() + if context['project'] and not context['mount']: + with api.set_context(context): + p = api.io.get_project(context['project']) + path = api.io.get_path_to(p) + _, mount = api.get_mount_from_path(path) + context['mount'] = mount + + for crumb in self.navigation.crumbs.iter(): + if crumb.key == 'home': + continue + label = context[crumb.key] or '' + crumb.label.setText(label) + crumb.setVisible(bool(label)) + + def _build_crumbs(self, context): + # Add home arrow + crumb = self.navigation.crumbs.add('') + crumb.key = 'home' + crumb.label.hide() + crumb.menu.aboutToShow.connect(partial( + self._build_crumb_menu, + crumb, + 'home', + )) + + # Add context crumbs + for key in ['location', 'mount', 'project', 'bin', 'asset']: + label = context[key] or '' + crumb = self.navigation.crumbs.add(label) + crumb.key = key + crumb.label.clicked.connect(partial( + self._on_crumb_clicked, + key + )) + crumb.menu.aboutToShow.connect(partial( + self._build_crumb_menu, + crumb, + key, + )) + if not context[key]: + crumb.hide() + if key == 'asset': + crumb.arrow.hide() + + # Update tab focus order + self.navigation._update_focus_order() + + def _on_sidebar_clicked(self, index): + pass + + def _on_selection_changed(self, selection): + print(selection) diff --git a/construct/ui/dialogs.py b/construct/ui/dialogs.py deleted file mode 100644 index 0adc272..0000000 --- a/construct/ui/dialogs.py +++ /dev/null @@ -1,313 +0,0 @@ -# -*- coding: utf-8 -*- - -from __future__ import absolute_import - -# Third party imports -from Qt import QtCore, QtGui, QtWidgets - -# Local imports -from .layouts import HBarLayout -from .scale import pt -from .theme import theme -from .widgets import H3, P - - -class FramelessDialog(QtWidgets.QDialog): - '''Frameless Dialog - - Arguments: - parent (QObject) - f (QtCore.Qt.WindowFlags) - ''' - - _resize_area_map = { - (False, False, False, False): None, - (True, False, False, False): 'left', - (True, True, False, False): 'topLeft', - (False, True, False, False): 'top', - (False, True, True, False): 'topRight', - (False, False, True, False): 'right', - (False, False, True, True): 'bottomRight', - (False, False, False, True): 'bottom', - (True, False, False, True): 'bottomLeft' - } - _cursor_map = { - None: QtCore.Qt.ArrowCursor, - 'left': QtCore.Qt.SizeHorCursor, - 'topLeft': QtCore.Qt.SizeFDiagCursor, - 'top': QtCore.Qt.SizeVerCursor, - 'topRight': QtCore.Qt.SizeBDiagCursor, - 'right': QtCore.Qt.SizeHorCursor, - 'bottomRight': QtCore.Qt.SizeFDiagCursor, - 'bottom': QtCore.Qt.SizeVerCursor, - 'bottomLeft': QtCore.Qt.SizeBDiagCursor - } - css_id = 'surface' - - def __init__(self, parent=None): - super(FramelessDialog, self).__init__(parent=parent) - - self._mouse_pressed = False - self._mouse_position = None - self._resize_area = None - self.resize_area_size = pt(5) - self.setMouseTracking(True) - self.setWindowFlags( - QtCore.Qt.Dialog | - QtCore.Qt.WindowStaysOnTopHint | - QtCore.Qt.FramelessWindowHint - ) - self.setObjectName(self.css_id) - self.setWindowTitle('construct') - self.setWindowIcon(theme.icon('brand/construct_icon-white.png')) - theme.apply(self) - - margins = (pt(10), pt(10), pt(10), pt(10)) - - self.header = QtWidgets.QWidget() - self.header_layout = HBarLayout() - self.header.setLayout(self.header_layout) - - self.body = QtWidgets.QWidget() - self.body_layout = QtWidgets.QVBoxLayout() - self.body_layout.setContentsMargins(0, 0, 0, 0) - self.body_layout.setSpacing(pt(10)) - self.body.setLayout(self.body_layout) - - self.footer = QtWidgets.QWidget() - self.footer_layout = HBarLayout() - self.footer.setLayout(self.footer_layout) - - self.layout = QtWidgets.QVBoxLayout() - self.layout.setContentsMargins(*margins) - self.layout.setSpacing(pt(8)) - self.layout.addWidget(self.header) - self.layout.addWidget(self.body) - self.layout.addWidget(self.footer) - self.setLayout(self.layout) - - @property - def resizing(self): - return bool(self._resize_area) - - def _check_resize_area(self, pos): - - x, y = pos.x(), pos.y() - self._resize_area = self._resize_area_map[( - x < self.resize_area_size, - y < self.resize_area_size, - x > self.width() - self.resize_area_size, - y > self.height() - self.resize_area_size, - )] - - def mousePressEvent(self, event): - - if event.buttons() & QtCore.Qt.LeftButton: - pos = event.pos() - self._check_resize_area(pos) - self._mouse_pressed = True - self._mouse_position = pos - - def mouseMoveEvent(self, event): - - if not self._mouse_pressed: - pos = event.pos() - self._check_resize_area(pos) - cursor = self._cursor_map.get(self._resize_area) - self.setCursor(cursor) - - if self._mouse_pressed: - vector = event.pos() - self._mouse_position - offset = event.globalPos() - - if self.resizing: - min_width = self.minimumWidth() - min_height = self.minimumHeight() - rect = self.geometry() - resize_area = self._resize_area.lower() - - if 'left' in resize_area: - new_width = rect.width() - vector.x() - if new_width > min_width: - rect.setLeft(offset.x()) - - if 'right' in resize_area: - new_width = rect.width() + vector.x() - if new_width > min_width: - rect.setRight(offset.x()) - - if 'top' in resize_area: - new_height = rect.height() - vector.y() - if new_height > min_height: - rect.setTop(offset.y()) - - if 'bottom' in resize_area: - new_height = rect.height() + vector.y() - if new_height > min_height: - rect.setBottom(offset.y()) - - self.setGeometry(rect) - - else: - self.move(self.mapToParent(vector)) - - def mouseReleaseEvent(self, event): - self._mouse_pressed = False - self._mouse_position = None - - -class Notification(FramelessDialog): - - def __init__( - self, - type, - message, - title=None, - icon=None, - close_icon=None, - short=None, - parent=None, - ): - super(Notification, self).__init__(parent=parent) - - self.setObjectName(type.lower()) - self.setMinimumWidth(pt(272)) - - self.header_message = P(message, parent=self) - self.header_message.setAlignment( - QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter - ) - self.body_message = P(message, parent=self) - self.body_message.setAlignment(QtCore.Qt.AlignLeft) - self.title = H3(title or type.title(), parent=self) - self.title.setAlignment( - QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter - ) - - # TODO: Creat icon widget - self.icon_widget = QtWidgets.QPushButton(parent=self) - self.icon_widget.hide() - self.icon_widget.setObjectName('icon') - self.icon_widget.setFlat(True) - self.icon_widget.setDisabled(True) - self.set_icon(icon) - - self.close_button = QtWidgets.QPushButton(parent=self) - self.close_button.setObjectName('icon') - self.close_button.setFlat(True) - self.close_button.clicked.connect(self.accept) - self.set_close_icon(close_icon) - - self.header_layout.left.addWidget(self.icon_widget) - self.header_layout.center.addWidget(self.header_message, stretch=1) - self.header_layout.center.addWidget(self.title, stretch=1) - self.header_layout.right.addWidget(self.close_button) - self.body_layout.addWidget(self.body_message, stretch=1) - - if short is not None: - self.set_short(short) - else: - self.set_short(len(message) < 72) - - def set_short(self, value): - self.is_short = value - if self.is_short: - self.header_message.show() - self.title.hide() - self.body.hide() - self.footer.hide() - self.adjustSize() - else: - self.title.show() - self.footer.setVisible(self.footer_layout.count()) - self.body.show() - self.header_message.hide() - self.adjustSize() - - def set_type(self, type): - self.setObjectName(type.lower()) - theme.apply(self) - - def set_icon(self, icon): - if icon: - self.icon = theme.icon(icon, parent=self.icon_widget) - self.icon_widget.setIcon(self.icon) - self.icon_widget.setIconSize(QtCore.QSize(pt(24), pt(24))) - self.icon_widget.show() - self.title.setAlignment(QtCore.Qt.AlignCenter) - else: - self.icon = None - self.icon_widget.hide() - self.header_message.setAlignment( - QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter - ) - self.title.setAlignment( - QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter - ) - - def set_close_icon(self, icon): - self.close_icon = theme.icon( - icon or 'close', - parent=self.close_button - ) - self.close_button.setIcon(self.close_icon) - self.close_button.setIconSize(QtCore.QSize(pt(24), pt(24))) - - -class Ask(FramelessDialog): - - def __init__( - self, - title, - message, - yes_label='Yes', - no_label='No', - icon=None, - parent=None, - ): - super(Ask, self).__init__(parent=parent) - self.setObjectName('surface') - self.setMinimumWidth(pt(272)) - - self.title = H3(title, parent=self) - self.title.setAlignment( - QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter - ) - self.body_message = P(message, parent=self) - self.body_message.setAlignment(QtCore.Qt.AlignLeft) - - # TODO: Creat icon widget - self.icon_widget = QtWidgets.QPushButton(parent=self) - self.icon_widget.hide() - self.icon_widget.setObjectName('icon') - self.icon_widget.setFlat(True) - self.icon_widget.setDisabled(True) - self.set_icon(icon) - - self.yes_button = QtWidgets.QPushButton(yes_label, parent=self) - self.yes_button.setObjectName('text-button') - self.yes_button.setFlat(True) - self.yes_button.clicked.connect(self.accept) - - self.no_button = QtWidgets.QPushButton(no_label, parent=self) - self.no_button.setObjectName('text-button') - self.no_button.setFlat(True) - self.no_button.clicked.connect(self.reject) - - self.header_layout.left.addWidget(self.icon_widget) - self.header_layout.center.addWidget(self.title, stretch=1) - self.body_layout.addWidget(self.body_message, stretch=1) - self.footer_layout.right.addWidget(self.yes_button) - self.footer_layout.right.addWidget(self.no_button) - - self.adjustSize() - - def set_icon(self, icon): - if icon: - self.icon = theme.icon(icon, parent=self.icon_widget) - self.icon_widget.setIcon(self.icon) - self.icon_widget.setIconSize(QtCore.QSize(pt(24), pt(24))) - self.icon_widget.show() - else: - self.icon = None - self.icon_widget.hide() diff --git a/construct/ui/dialogs/__init__.py b/construct/ui/dialogs/__init__.py new file mode 100644 index 0000000..48cdeb7 --- /dev/null +++ b/construct/ui/dialogs/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +''' +isort:skip_file +''' + +# Local imports +from .frameless import * +from .notification import * +from .ask import * +from .bookmarks import * diff --git a/construct/ui/dialogs/ask.py b/construct/ui/dialogs/ask.py new file mode 100644 index 0000000..c23fb9b --- /dev/null +++ b/construct/ui/dialogs/ask.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import + +# Third party imports +from Qt import QtCore + +# Local imports +from ..scale import px +from ..widgets import H3, Button, Glyph, P +from . import FramelessDialog + + +__all__ = [ + 'Ask', +] + + +class Ask(FramelessDialog): + + css_properties = { + 'theme': 'surface', + 'border': True, + } + + def __init__( + self, + title, + message, + yes_label='Yes', + no_label='No', + icon=None, + parent=None, + ): + super(Ask, self).__init__(parent=parent) + self.setMinimumWidth(px(272)) + + self.title = H3(title, parent=self) + self.title.setAlignment( + QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter + ) + self.body_message = P(message, parent=self) + self.body_message.setAlignment(QtCore.Qt.AlignLeft) + + self.glyph = Glyph(icon or 'question', (24, 24)) + if not icon: + self.glyph.hide() + + self.yes_button = Button(yes_label, parent=self) + self.yes_button.clicked.connect(self.accept) + + self.no_button = Button(no_label, parent=self) + self.no_button.clicked.connect(self.reject) + + self.header_layout.left.addWidget(self.glyph) + self.header_layout.center.addWidget(self.title, stretch=1) + self.body_layout.addWidget(self.body_message, stretch=1) + self.footer_layout.right.addWidget(self.yes_button) + self.footer_layout.right.addWidget(self.no_button) + + self.adjustSize() + + def set_icon(self, icon): + if icon: + self.glyph.set_icon(icon, (24, 24)) + self.glyph.show() + else: + self.glyph.hide() diff --git a/construct/ui/dialogs/bookmarks.py b/construct/ui/dialogs/bookmarks.py new file mode 100644 index 0000000..2db1936 --- /dev/null +++ b/construct/ui/dialogs/bookmarks.py @@ -0,0 +1,274 @@ +# -*- coding: utf-8 -*- + +# Standard library imports +from functools import partial + +# Third party imports +from Qt import QtCore, QtWidgets + +# Local imports +from ..layouts import HBarLayout, VBarLayout +from ..scale import px +from ..widgets import ( + H4, + Button, + Frameless, + Glyph, + HLine, + IconButton, + P, + Widget, +) + + +__all__ = [ + 'BookmarksDialog', +] + + +class BookmarkWidget(Widget, QtWidgets.QWidget): + '''Bookmark view.''' + + def __init__(self, data, *args, **kwargs): + super(BookmarkWidget, self).__init__(*args, **kwargs) + + self.data = data + self.layout = HBarLayout(parent=self) + self.icon = Glyph( + icon=data.get('icon', 'square'), + icon_size=(14, 14), + parent=self, + ) + self.label = P(data['name'], parent=self) + self.label.setAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter) + self.label.setSizePolicy( + QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Expanding + ) + self.remove = IconButton( + icon='minus', + icon_size=(14, 14), + parent=self, + ) + self.remove.hide() + + self.setToolTip(data['uri']) + self.setMouseTracking(True) + + self.layout.left.addWidget(self.icon) + self.layout.center.addWidget(self.label) + self.layout.right.addWidget(self.remove) + + def enterEvent(self, event): + self.remove.show() + + def leaveEvent(self, event): + self.remove.hide() + + +class BookmarksBase(Frameless, QtWidgets.QDialog): + '''Base view for displaying and editing bookmarks. This is not connected + to any signals.''' + + css_id = 'bookmarks' + css_properties = { + 'theme': 'surface', + 'windowTitle': 'Bookmarks', + 'windowIcon': 'bookmarks', + } + added = QtCore.Signal() + removed = QtCore.Signal() + edited = QtCore.Signal() + + def __init__(self, *args, **kwargs): + super(BookmarksBase, self).__init__(*args, **kwargs) + + self.setMinimumWidth(px(256)) + self.setMinimumHeight(px(384)) + + self.header = H4('Bookmarks', parent=self) + self.header.setAlignment(QtCore.Qt.AlignCenter) + self.header.setFixedHeight(px(32)) + + self.name_label = P('name', parent=self) + self.name_label.setAlignment( + QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter + ) + self.name = QtWidgets.QLineEdit(parent=self) + self.add = Button( + 'add', + parent=self, + ) + self.add.setSizePolicy( + QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Expanding + ) + self.remove = Button( + 'remove', + parent=self, + ) + self.remove.setSizePolicy( + QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Expanding + ) + self.edit = Button( + 'edit', + parent=self, + ) + self.edit.setSizePolicy( + QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Expanding + ) + self.list = QtWidgets.QListWidget(parent=self) + + self.tools = QtWidgets.QHBoxLayout() + self.tools.addWidget(self.remove) + self.tools.addWidget(self.edit) + self.tools.addWidget(self.add) + + self.form = QtWidgets.QFormLayout() + self.form.setContentsMargins(*px(16, 16, 16, 16)) + self.form.setSpacing(px(16)) + self.form.addRow(self.name_label, self.name) + self.form.addRow(self.tools) + + self.layout = VBarLayout(parent=self) + self.layout.setSpacing(0) + self.layout.top.setSpacing(0) + self.layout.top.addWidget(self.header) + self.layout.top.addWidget(HLine(self)) + self.layout.top.addLayout(self.form) + self.layout.top.addWidget(HLine(self)) + self.layout.center.addWidget(self.list) + self.layout.center.setContentsMargins(*px(1, 0, 1, 1)) + self.setLayout(self.layout) + + def add_bookmark_item(self, bookmark, remove_callback): + widget = BookmarkWidget(bookmark, parent=self.list) + widget.remove.clicked.connect(remove_callback) + item = QtWidgets.QListWidgetItem(parent=self.list) + self.list.addItem(item) + self.list.setItemWidget(item, widget) + + +class BookmarksDialog(BookmarksBase): + '''Bookmarks Dialog. This is the stateful version of the BookmarksBase. + + Required State: + api (API): Used to retrieve and persist bookmarks in user_cache. + bookmarks (List[Dict]): Storage for bookmarks. + context (Context): Causes a the dialog to refresh. + uri (str): Set when a bookmark is clicked. + ''' + + def __init__(self, state, *args, **kwargs): + super(BookmarksDialog, self).__init__(*args, **kwargs) + + self.remove.clicked.connect(self._on_rem_clicked) + self.add.clicked.connect(self._on_add_or_edit_clicked) + self.edit.clicked.connect(self._on_add_or_edit_clicked) + self.list.itemClicked.connect(self._on_bookmark_clicked) + self.setAttribute(QtCore.Qt.WA_DeleteOnClose) + + self.state = state + self.state['bookmarks'].changed.connect(self._refresh) + self.state['context'].changed.connect(self._refresh) + self._refresh() + self.installEventFilter(self) + + def eventFilter(self, object, event): + if event.type() == QtCore.QEvent.WindowDeactivate: + self.accept() + return super(BookmarksDialog, self).eventFilter(object, event) + + def _refresh(self): + self.list.clear() + + bookmark = self._get_bookmark(self.state['uri'].get()) + if bookmark: + self.name.setText(bookmark['name']) + self.add.setEnabled(False) + self.edit.setEnabled(True) + self.remove.setEnabled(True) + else: + self.name.setText(self._name_from_context(self.state['context'])) + self.add.setEnabled(True) + self.edit.setEnabled(False) + self.remove.setEnabled(False) + + bookmarks = self.state['bookmarks'].get() + for bookmark in reversed(bookmarks): + self.add_bookmark_item( + bookmark, + partial(self._remove_bookmark, bookmark) + ) + + def _name_from_context(self, context): + if context['asset']: + parts = [context['location'], context['project'], context['asset']] + elif context['bin']: + parts = [context['location'], context['project'], context['bin']] + elif context['project']: + parts = [context['location'], context['project']] + elif context['mount']: + parts = [context['location'], context['mount']] + elif context['location']: + parts = [context['location']] + else: + parts = [] + return ' / '.join([p.title() for p in parts]) + + def _on_rem_clicked(self): + bookmark = self._get_bookmark(self.state['uri'].get()) + if bookmark: + self._remove_bookmark(bookmark) + + def _on_add_or_edit_clicked(self): + self._cache_bookmark(dict( + name=self.name.text(), + uri=self.state['uri'].get(), + context=dict(self.state['context'].get()) + )) + + def _on_bookmark_clicked(self, item): + widget = self.list.itemWidget(item) + bookmark = widget.data + self.state.set('uri', bookmark['uri']) + self.state.set('context', bookmark['context']) + self.list.clearSelection() + + def _remove_bookmark(self, bookmark): + api = self.state['api'].get() + bookmarks = api.user_cache.get('bookmarks', []) + try: + bookmarks.remove(bookmark) + except IndexError: + return + + api.user_cache.set('bookmarks', bookmarks) + self.state.set('bookmarks', bookmarks) + + def _cache_bookmark(self, bookmark): + api = self.state['api'].get() + bookmarks = api.user_cache.get('bookmarks', []) + for b in bookmarks: + if b['uri'] == bookmark['uri']: + b['uri'] = bookmark['uri'] + b['name'] = bookmark['name'] + b['context'] = bookmark['context'] + break + else: + bookmarks.append(dict( + uri=bookmark['uri'], + name=bookmark['name'], + context=bookmark['context'], + )) + + api.user_cache.set('bookmarks', bookmarks) + self.state.set('bookmarks', bookmarks) + + def _get_bookmark(self, uri): + bookmarks = self.state['bookmarks'].get() + for bookmark in bookmarks: + if uri == bookmark['uri']: + return bookmark diff --git a/construct/ui/dialogs/frameless.py b/construct/ui/dialogs/frameless.py new file mode 100644 index 0000000..c2bb1e7 --- /dev/null +++ b/construct/ui/dialogs/frameless.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import + +# Third party imports +from Qt import QtCore, QtWidgets + +# Local imports +from ..layouts import HBarLayout +from ..scale import px +from ..widgets import Frameless + + +__all__ = [ + 'FramelessDialog', +] + + +class FramelessDialog(Frameless, QtWidgets.QDialog): + + css_id = '' + css_properties = { + 'theme': 'surface' + } + + def __init__(self, *args, **kwargs): + super(FramelessDialog, self).__init__(*args, **kwargs) + + self.setWindowFlags( + QtCore.Qt.Dialog | + QtCore.Qt.WindowStaysOnTopHint | + QtCore.Qt.FramelessWindowHint + ) + + self.header = QtWidgets.QWidget() + self.header_layout = HBarLayout() + self.header.setLayout(self.header_layout) + + self.body = QtWidgets.QWidget() + self.body_layout = QtWidgets.QVBoxLayout() + self.body_layout.setContentsMargins(0, 0, 0, 0) + self.body_layout.setSpacing(px(16)) + self.body.setLayout(self.body_layout) + + self.footer = QtWidgets.QWidget() + self.footer_layout = HBarLayout() + self.footer.setLayout(self.footer_layout) + + self.layout = QtWidgets.QVBoxLayout() + self.layout.setContentsMargins(*px(16, 8, 16, 8)) + self.layout.setSpacing(px(16)) + self.layout.addWidget(self.header) + self.layout.addWidget(self.body) + self.layout.addWidget(self.footer) + self.setLayout(self.layout) diff --git a/construct/ui/dialogs/notification.py b/construct/ui/dialogs/notification.py new file mode 100644 index 0000000..bf26636 --- /dev/null +++ b/construct/ui/dialogs/notification.py @@ -0,0 +1,119 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import + +# Third party imports +from Qt import QtCore + +# Local imports +from ..scale import px +from ..theme import theme +from ..widgets import H3, Glyph, IconButton, P +from . import FramelessDialog + + +__all__ = [ + 'Notification', +] + + +class Notification(FramelessDialog): + '''Notification dialog used to show quick messages to users. + + Arguments: + type (str): Type or theme. One of ['alert', 'error', 'info', 'success'] + message (str): Message to display + title (str): Dialog title + icon (str): Name of icon to display along title / message + close_icon (str): Name of close icon + short (bool): Hides title when True + + Example: + >>> alert = Notification('alert', 'Something Happened!') + >>> alert.exec_() + ''' + + def __init__( + self, + type, + message, + title=None, + icon=None, + close_icon=None, + short=None, + parent=None, + ): + super(Notification, self).__init__(parent=parent) + + self.set_type(type) + self.setMinimumWidth(px(272)) + + self.header_message = P(message, parent=self) + self.header_message.setAlignment( + QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter + ) + self.body_message = P(message, parent=self) + self.body_message.setAlignment(QtCore.Qt.AlignLeft) + self.title = H3(title or type.title(), parent=self) + self.title.setAlignment( + QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter + ) + + self.icon = icon + self.glyph = Glyph( + icon=icon or 'circle_outline', + icon_size=(24, 24), + parent=self, + ) + if not icon: + self.glyph.hide() + + self.close_button = IconButton( + icon=close_icon or 'close', + icon_size=(24, 24), + parent=self, + ) + self.close_button.clicked.connect(self.accept) + + self.header_layout.left.addWidget(self.glyph) + self.header_layout.center.addWidget(self.header_message, stretch=1) + self.header_layout.center.addWidget(self.title, stretch=1) + self.header_layout.right.addWidget(self.close_button) + self.body_layout.addWidget(self.body_message, stretch=1) + + if short is not None: + self.set_short(short) + else: + self.set_short(len(message) < 72) + + def set_short(self, value): + self.is_short = value + if self.is_short: + self.header_message.show() + self.title.hide() + self.body.hide() + self.footer.hide() + self.adjustSize() + else: + if self.icon: + self.title.setAlignment(QtCore.Qt.AlignCenter) + self.title.show() + self.footer.setVisible(self.footer_layout.count()) + self.body.show() + self.header_message.hide() + self.adjustSize() + + def set_type(self, type): + self.setProperty('theme', type.lower()) + theme.apply(self) + + def set_icon(self, icon): + self.icon = icon + if icon: + self.glyph.set_icon(icon, (24, 24)) + self.glyph.show() + else: + self.glyph.hide() + + def set_close_icon(self, icon): + self.close_icon.set_icon(icon, (24, 24)) diff --git a/construct/ui/eventloop.py b/construct/ui/eventloop.py index 04a4ca3..8b3271e 100644 --- a/construct/ui/eventloop.py +++ b/construct/ui/eventloop.py @@ -2,8 +2,8 @@ from __future__ import absolute_import -# Standard library imports -from functools import wraps +# Local imports +from ..compat import wraps class EventLoop(object): @@ -15,6 +15,9 @@ def __init__(self, event_loop, standalone): self.standalone = standalone self.running = not self.standalone + def quit(self): + self.event_loop.quit() + def start(self): if self.running: return @@ -56,8 +59,6 @@ def requires_event_loop(fn): @wraps(fn) def start_then_call(*args, **kwargs): - from Qt.QtCore import QTimer - event_loop = get_event_loop() result = fn(*args, **kwargs) return result diff --git a/construct/ui/launcher/__init__.py b/construct/ui/launcher/__init__.py new file mode 100644 index 0000000..9f6f204 --- /dev/null +++ b/construct/ui/launcher/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +''' +isort:skip_file +''' + +# Local imports +from .__main__ import main +from .app import Launcher diff --git a/construct/ui/launcher/__main__.py b/construct/ui/launcher/__main__.py new file mode 100644 index 0000000..2e51c20 --- /dev/null +++ b/construct/ui/launcher/__main__.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import + +# Third party imports +from Qt import QtWidgets + +# Local imports +import construct +from construct.ui.theme import theme +from construct.ui.eventloop import get_event_loop + + +def main(): + + api = construct.API() + + event_loop = get_event_loop() + + app = api.ui.launcher() + app.show() + + if QtWidgets.QSystemTrayIcon.isSystemTrayAvailable(): + + def on_tray_activated(reason): + if reason == QtWidgets.QSystemTrayIcon.Context: + return + app.setVisible(not app.isVisible()) + + tray = QtWidgets.QSystemTrayIcon(parent=app) + tray.setIcon(theme.icon('icons/construct.svg')) + tray.activated.connect(on_tray_activated) + tray_menu = QtWidgets.QMenu(parent=app) + tray_menu.addAction( + theme.icon('close', parent=app), + 'Close', + event_loop.quit + ) + tray.setContextMenu(tray_menu) + tray.show() + + event_loop.start() + + +if __name__ == '__main__': + main() diff --git a/construct/ui/launcher/app.py b/construct/ui/launcher/app.py new file mode 100644 index 0000000..e75110d --- /dev/null +++ b/construct/ui/launcher/app.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import + +# Local imports +from ..app import App +from ..theme import theme + + +class Launcher(App): + + css_id = '' + css_properties = { + 'theme': 'surface', + 'border': True, + 'windowTitle': 'Construct Launcher', + 'windowIcon': theme.resources.get('icons/construct.svg'), + } + + def __init__(self, *args, **kwargs): + super(Launcher, self).__init__(*args, **kwargs) diff --git a/construct/ui/layouts/__init__.py b/construct/ui/layouts/__init__.py new file mode 100644 index 0000000..32cdb9a --- /dev/null +++ b/construct/ui/layouts/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +''' +isort:skip_file +''' + +# Local imports +from .bar import BarLayout, HBarLayout, VBarLayout +from .flow import FlowLayout diff --git a/construct/ui/layouts/bar.py b/construct/ui/layouts/bar.py new file mode 100644 index 0000000..1dbac4b --- /dev/null +++ b/construct/ui/layouts/bar.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import + +# Third party imports +from Qt import QtCore +from Qt.QtWidgets import QBoxLayout + +# Local imports +from ..scale import pt + + +class BarLayout(QBoxLayout): + '''BarLayout like BoxLayout but with 3 sub layouts allowing users to add + widgets to the start, middle and end of the layout. + + |start | -------- middle -------- | end| + + Attributes: + start - QBoxLayout with default direction set to LeftToRight + middle - QBoxLayout with default direction set to LeftToRight + end - QBoxLayout with default direction set to LeftToRight + ''' + + def __init__(self, direction=None, parent=None): + direction = direction or self.LeftToRight + super(BarLayout, self).__init__(direction, parent) + + self.start = QBoxLayout(direction) + self.start.setSpacing(pt(8)) + self.left = self.start + self.top = self.start + + self.middle = QBoxLayout(direction) + self.middle.setSpacing(pt(8)) + self.center = self.middle + + self.end = QBoxLayout(direction) + self.end.setSpacing(pt(8)) + self.right = self.end + self.bottom = self.end + + self.addLayout(self.start) + self.addLayout(self.middle) + self.addLayout(self.end) + + self.setStretch(1, 1) + self.setSpacing(pt(8)) + self.setContentsMargins(0, 0, 0, 0) + + self.setDirection(direction) + + def count(self): + '''Sum of the count of all sub layouts''' + return self.start.count() + self.middle.count() + self.end.count() + + def setDirection(self, direction): + '''Realign sub-layouts based on direction''' + + if direction in (self.LeftToRight, self.RightToLeft): + self.start.setAlignment(QtCore.Qt.AlignLeft|QtCore.Qt.AlignVCenter) + self.middle.setAlignment(QtCore.Qt.AlignCenter) + self.end.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignVCenter) + elif direction in (self.TopToBottom, self.BottomToTop): + self.start.setAlignment(QtCore.Qt.AlignTop|QtCore.Qt.AlignHCenter) + self.middle.setAlignment(QtCore.Qt.AlignTop|QtCore.Qt.AlignHCenter) + self.end.setAlignment(QtCore.Qt.AlignBottom|QtCore.Qt.AlignHCenter) + else: + raise ValueError( + 'Got %s expected QBoxLayout.Direction' % direction + ) + + self.start.setDirection(direction) + self.middle.setDirection(direction) + self.end.setDirection(direction) + super(BarLayout, self).setDirection(direction) + + +class HBarLayout(BarLayout): + '''Horizontal BarLayout. + + Attributes: + left - QBoxLayout alignment set to left + center - QBoxLayout alignment set to center + right - QBoxLayout alignment set to right + ''' + + def __init__(self, parent=None): + super(HBarLayout, self).__init__(QBoxLayout.LeftToRight, parent) + + +class VBarLayout(BarLayout): + '''Vertical BarLayout. + + Attributes: + top - QBoxLayout alignment set to top + center - QBoxLayout alignment set to center + bottom - QBoxLayout alignment set to bottom + ''' + + def __init__(self, parent=None): + super(VBarLayout, self).__init__(QBoxLayout.TopToBottom, parent) diff --git a/construct/ui/layouts.py b/construct/ui/layouts/flow.py similarity index 54% rename from construct/ui/layouts.py rename to construct/ui/layouts/flow.py index 841ad89..d2c48a8 100644 --- a/construct/ui/layouts.py +++ b/construct/ui/layouts/flow.py @@ -3,106 +3,13 @@ from __future__ import absolute_import # Third party imports -from Qt import QtCore -from Qt.QtWidgets import QBoxLayout, QLayout, QSizePolicy, QStyle +from Qt import QtCore, QtWidgets # Local imports -from .scale import pt +from ..scale import pt -class BarLayout(QBoxLayout): - '''BarLayout like BoxLayout but with 3 sub layouts allowing users to add - widgets to the start, middle and end of the layout. - - |start | -------- middle -------- | end| - - Attributes: - start - QBoxLayout with default direction set to LeftToRight - middle - QBoxLayout with default direction set to LeftToRight - end - QBoxLayout with default direction set to LeftToRight - ''' - - def __init__(self, direction=None, parent=None): - direction = direction or self.LeftToRight - super(BarLayout, self).__init__(direction, parent) - - self.start = QBoxLayout(direction) - self.start.setSpacing(pt(8)) - self.left = self.start - self.top = self.start - - self.middle = QBoxLayout(direction) - self.middle.setSpacing(pt(8)) - self.center = self.middle - - self.end = QBoxLayout(direction) - self.end.setSpacing(pt(8)) - self.right = self.end - self.bottom = self.end - - self.addLayout(self.start) - self.addLayout(self.middle) - self.addLayout(self.end) - - self.setStretch(1, 1) - self.setSpacing(pt(8)) - self.setContentsMargins(0, 0, 0, 0) - - self.setDirection(direction) - - def count(self): - '''Sum of the count of all sub layouts''' - return self.start.count() + self.middle.count() + self.end.count() - - def setDirection(self, direction): - '''Realign sub-layouts based on direction''' - - if direction in (self.LeftToRight, self.RightToLeft): - self.start.setAlignment(QtCore.Qt.AlignLeft|QtCore.Qt.AlignVCenter) - self.middle.setAlignment(QtCore.Qt.AlignCenter) - self.end.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignVCenter) - elif direction in (self.TopToBottom, self.BottomToTop): - self.start.setAlignment(QtCore.Qt.AlignTop|QtCore.Qt.AlignHCenter) - self.middle.setAlignment(QtCore.Qt.AlignCenter) - self.end.setAlignment(QtCore.Qt.AlignBottom|QtCore.Qt.AlignHCenter) - else: - raise ValueError( - 'Got %s expected QBoxLayout.Direction' % direction - ) - - self.start.setDirection(direction) - self.middle.setDirection(direction) - self.end.setDirection(direction) - super(BarLayout, self).setDirection(direction) - - -class HBarLayout(BarLayout): - '''Horizontal BarLayout. - - Attributes: - left - QBoxLayout alignment set to left - center - QBoxLayout alignment set to center - right - QBoxLayout alignment set to right - ''' - - def __init__(self, parent=None): - super(HBarLayout, self).__init__(BarLayout.LeftToRight, parent) - - -class VBarLayout(BarLayout): - '''Vertical BarLayout. - - Attributes: - top - QBoxLayout alignment set to top - center - QBoxLayout alignment set to center - bottom - QBoxLayout alignment set to bottom - ''' - - def __init__(self, parent=None): - super(VBarLayout, self).__init__(BarLayout.TopToBottom, parent) - - -class FlowLayout(QLayout): +class FlowLayout(QtWidgets.QLayout): '''A Layout that wraps widgets on overflow. ____________ | [] [] [] | @@ -191,8 +98,8 @@ def doLayout(self, rect, testOnly=False): space = self.horizontalSpacing() if space < 0: space = w.style().layoutSpacing( - QSizePolicy.QPushButton, - QSizePolicy.QPushButton, + QtWidgets.QSizePolicy.QPushButton, + QtWidgets.QSizePolicy.QPushButton, QtCore.Qt.Horizontal, ) diff --git a/construct/ui/manager.py b/construct/ui/manager.py index 3930f12..9ccf598 100644 --- a/construct/ui/manager.py +++ b/construct/ui/manager.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- +# Standard library imports +from collections import defaultdict # Local imports from .eventloop import requires_event_loop @@ -14,6 +16,7 @@ def __init__(self, api): self.api = api self.resources = Resources([]) self.theme = theme + self.menu_registry = defaultdict(list) def load(self): self.resources = Resources(self.api.path) @@ -23,6 +26,9 @@ def load(self): self.api.extend('success', self.success) self.api.extend('info', self.info) self.api.extend('ask', self.ask) + self.api.extend('launcher', self.launcher) + self.api.extend('register_menu', self.register_menu) + self.api.extend('unregister_menu', self.unregister_menu) def unload(self): self.resources = Resources([]) @@ -30,7 +36,49 @@ def unload(self): self.api.unextend('alert') self.api.unextend('error') self.api.unextend('success') + self.api.unextend('info') self.api.unextend('ask') + self.api.unextend('launcher') + self.api.unextend('register_menu') + self.api.unextend('unregister_menu') + + def request_menu(self, identifier, context, menu=None): + from Qt import QtCore, QtWidgets + + menu = menu or QtWidgets.QMenu() + menu.setWindowFlags( + menu.windowFlags() | QtCore.Qt.NoDropShadowWindowHint + ) + + for item in self.menu_registry[identifier]: + item(menu, context) + + for child in menu.children(): + if isinstance(child, QtWidgets.QMenu): + child.setWindowFlags( + child.windowFlags() | QtCore.Qt.NoDropShadowWindowHint + ) + + return menu + + def register_menu(self, identifier, handler): + if handler not in self.menu_registry[identifier]: + self.menu_registry[identifier].append(handler) + + def unregister_menu(self, identifer, handler): + if handler in self.menu_registry[identifer]: + self.menu_registry[identifer].remove(handler) + + @requires_event_loop + def launcher(self, uri=None): + '''Shows the Launcher application. + + Arguments: + uri (str): Location where Launcher should start + ''' + + from .launcher import Launcher + return Launcher(self.api, uri) @requires_event_loop def alert(self, message, title=None, short=None): diff --git a/construct/ui/models/__init__.py b/construct/ui/models/__init__.py new file mode 100644 index 0000000..cbe23af --- /dev/null +++ b/construct/ui/models/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +''' +isort:skip_file +''' + +# Local imports +from .tree import * diff --git a/construct/ui/models/tree.py b/construct/ui/models/tree.py new file mode 100644 index 0000000..8cb74f2 --- /dev/null +++ b/construct/ui/models/tree.py @@ -0,0 +1,313 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import + +# Third party imports +from Qt import QtCore, QtWidgets + +# Local imports +from ..scale import px +from ..theme import theme + + +class Node(object): + + def __init__(self, value, parent=None): + self._value = value + self._parent = parent + self._children = [] + + if parent: + parent.addChild(self) + + def context(self): + return self._value.get('context', {}) + + def isTopLevel(self): + return self.parent() and not self.parent().parent() + + def type(self): + return self._value.get('_type', 'group') + + def value(self): + return self._value + + def parent(self): + return self._parent + + def row(self): + return self._parent._children.index(self) + + def child(self, row): + try: + return self._children[row] + except IndexError: + pass + + def children(self): + return self._children + + def addChild(self, node): + self._children.append(node) + + def insertChild(self, index, child): + try: + self._children.insert(index, child) + child._parent = self + return True + except IndexError: + return False + + def removeChild(self, index): + + try: + child = self._children.pop(index) + child._parent = None + return True + except IndexError: + return False + + def childCount(self): + return len(self._children) + + def data(self, column): + if column == 0: + return self._value['name'] + + def setData(self, column, value): + if column == 0: + self._value['name'] = value + + +class TreeModel(QtCore.QAbstractItemModel): + + selectable_types = ['mount', 'project', 'asset'] + + def __init__(self, root=None, parent=None): + super(TreeModel, self).__init__(parent) + self._root = root or Node({'name': 'root'}) + + def root(self): + return self._root + + def context(self): + return self._root.context() + + def type(self): + return self._type + + def flags(self, index): + if not index.isValid(): + return None + + node = self.getNode(index) + if node.type() in self.selectable_types: + return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + + return QtCore.Qt.ItemIsEnabled + + def getNode(self, index): + if index.isValid(): + node = index.internalPointer() + if node: + return node + return self._root + + def parent(self, index): + node = self.getNode(index) + parent = node.parent() + + if self._root in [node, parent]: + return QtCore.QModelIndex() + + return self.createIndex(parent.row(), 0, parent) + + def index(self, row, column, index=QtCore.QModelIndex()): + parent = self.getNode(index) + child = parent.child(row) + + if child: + return self.createIndex(row, column, child) + else: + return QtCore.QModelIndex() + + def rowCount(self, index): + if not index.isValid(): + parent = self._root + else: + parent = index.internalPointer() + + return parent.childCount() + + def columnCount(self, index): + return 1 + + def data(self, index, role): + if not index.isValid(): + return None + + node = index.internalPointer() + + if role == QtCore.Qt.SizeHintRole: + return QtCore.QSize(px(32), px(32)) + + if role == QtCore.Qt.DisplayRole or role == QtCore.Qt.EditRole: + return node.data(index.column()) + + if role == QtCore.Qt.DecorationRole: + # if node.type() == 'group': + # return theme.icon('folder') + # return theme.icon('square') + return + + def setData(self, index, value, role=QtCore.Qt.EditRole): + if index.isValid(): + if role == QtCore.Qt.EditRole: + node = index.internalPointer() + node.setData(index.column(), value) + return True + return False + + def insertRows(self, row, rows, parent=QtCore.QModelIndex()): + parent = self.getNode(parent) + + self.beginInsertRows(parent, row, row + rows - 1) + + for row in range(rows): + child = Node({'name': 'Untitled'}) + success = parent.insertChild(row, child) + + self.endInsertRows() + return success + + def removeRows(self, row, rows, parent=QtCore.QModelIndex()): + parent = self.getNode(parent) + self.beginRemoveRows(parent, row, row + rows - 1) + + for row in range(rows): + success = parent.removeChild(row) + + self.endRemoveRows() + return success + + +class TreeItemDelegate(QtWidgets.QStyledItemDelegate): + + def __init__(self, parent): + super(TreeItemDelegate, self).__init__(parent) + + def sizeHint(self, option, index): + if not index: + return super(TreeItemDelegate, self).sizeHint(option, index) + + node = option.widget.model().getNode(index) + return QtCore.QSize(*px(32, 32)) + + def paint(self, painter, option, index): + if not index: + return super(TreeItemDelegate, self).paint(painter, option, index) + + self.initStyleOption(option, index) + + node = option.widget.model().getNode(index) + + groups = ['group', 'mount', 'location', 'bin'] + if node.isTopLevel() and node.type() in groups: + option.font.setPixelSize(px(16)) + else: + option.font.setPixelSize(px(14)) + + super(TreeItemDelegate, self).paint(painter, option, index) + + +def LocationsTreeModel(api, context, parent=None): + '''Create a hierarchy of Node objects from a locations dict.''' + + root = Node({'name': 'root', 'context': context}) + + for location, mounts in sorted(api.get_locations().items()): + parent_node = Node({'name': location, '_type': 'location'}, root) + for mount, path in sorted(mounts.items()): + Node( + { + '_type': 'mount', + 'name': mount, + 'location': location, + 'path': path, + }, + parent_node, + ) + + return TreeModel(root, parent) + + +def MountsProjectsTreeModel(api, context, parent=None): + '''Create a hierarchy of Node objects from a list of projects.''' + + root = Node({'name': 'root', 'context': context}) + + mounts = api.get_locations()[context['location']] + for mount, path in sorted(mounts.items()): + parent_node = Node( + { + '_type': 'mount', + 'name': mount, + 'location': context['location'], + 'path': path, + }, + root + ) + projects = api.io.get_projects(context['location'], mount) + for project in sorted(projects, key=lambda p: p['name']): + Node(project, parent_node) + + return TreeModel(root, parent) + + +def ProjectsTreeModel(api, context, mount, parent=None): + '''Create a hierarchy of Node objects from a list of projects.''' + + root = Node({'name': 'root', 'context': context}) + projects = api.io.get_projects(context['location'], mount) + for project in sorted(projects, key=lambda p: p['name']): + Node(project, root) + + return TreeModel(root, parent) + + +def AssetsTreeModel(api, context, project, bin, parent=None): + '''Create a hierarchy of Node objects from a list of assets.''' + + root = Node({'name': 'root', 'context': context}) + assets = list(api.io.get_assets(project, bin=bin)) + + groups = {} + sorted_assets = sorted( + assets, + key=lambda a: (a['group'] or 'ZZ', a['name']) + ) + for asset in sorted_assets: + + parent_node = root + + if bin is None: + asset_bin = asset['bin'] + if asset_bin and asset_bin not in groups: + groups[asset_bin] = Node( + {'name': asset_bin, '_type': 'bin'}, + root + ) + + parent_node = groups.get(asset_bin, root) + + group = asset['group'] + if group and (bin, group) not in groups: + groups[(bin, group)] = Node( + {'name': group, '_type': 'group'}, + parent_node + ) + + parent_node = groups.get((bin, group), parent_node) + Node(asset, parent_node) + + return TreeModel(root, parent) diff --git a/construct/ui/resources/fonts/construct.ttf b/construct/ui/resources/fonts/construct.ttf index 81de8ab..0f7275d 100644 Binary files a/construct/ui/resources/fonts/construct.ttf and b/construct/ui/resources/fonts/construct.ttf differ diff --git a/construct/ui/resources/fonts/construct_charmap.json b/construct/ui/resources/fonts/construct_charmap.json index c1a0192..b50f3ad 100644 --- a/construct/ui/resources/fonts/construct_charmap.json +++ b/construct/ui/resources/fonts/construct_charmap.json @@ -1,46 +1,46 @@ { - "alert": "\ue901", - "arrow_down": "\ue902", - "arrow_down_solid": "\ue903", - "arrow_left": "\ue904", - "arrow_left_solid": "\ue905", - "arrow_right": "\ue906", - "arrow_right_solid": "\ue907", - "arrow_up": "\ue908", - "arrow_up_solid": "\ue909", - "back": "\ue90a", - "bookmark": "\ue90b", - "bookmark_outline": "\ue90c", - "check": "\ue90d", - "checkbox_check": "\ue90e", - "checkbox_off": "\ue90f", - "checkbox_on": "\ue910", - "circle": "\ue911", - "circle_outline": "\ue912", - "close": "\ue913", - "collection": "\ue914", - "construct": "\ue915", - "error": "\ue916", - "filter": "\ue917", - "folder": "\ue918", - "folder_open": "\ue919", - "forward": "\ue91a", - "grid": "\ue91b", - "group_list": "\ue91c", - "home": "\ue91d", - "list": "\ue91e", - "menu": "\ue91f", - "menu_dots": "\ue920", - "minus": "\ue921", - "plus": "\ue922", - "question": "\ue923", - "refresh": "\ue924", - "search": "\ue925", - "settings": "\ue926", - "sort": "\ue927", - "square": "\ue928", - "square_outline": "\ue929", - "tree": "\ue92a", - "triangle": "\ue92b", + "alert": "\ue901", + "arrow_down": "\ue902", + "arrow_down_solid": "\ue903", + "arrow_left": "\ue904", + "arrow_left_solid": "\ue905", + "arrow_right": "\ue906", + "arrow_right_solid": "\ue907", + "arrow_up": "\ue908", + "arrow_up_solid": "\ue909", + "back": "\ue90a", + "bookmark": "\ue90b", + "bookmark_outline": "\ue90c", + "check": "\ue90d", + "checkbox_check": "\ue90e", + "checkbox_off": "\ue90f", + "checkbox_on": "\ue910", + "circle": "\ue911", + "circle_outline": "\ue912", + "close": "\ue913", + "collection": "\ue914", + "construct": "\ue915", + "error": "\ue916", + "filter": "\ue917", + "folder": "\ue918", + "folder_open": "\ue919", + "forward": "\ue91a", + "grid": "\ue91b", + "group_list": "\ue91c", + "home": "\ue91d", + "list": "\ue91e", + "menu": "\ue91f", + "menu_dots": "\ue920", + "minus": "\ue921", + "plus": "\ue922", + "question": "\ue923", + "refresh": "\ue924", + "search": "\ue925", + "settings": "\ue926", + "sort": "\ue927", + "square": "\ue928", + "square_outline": "\ue929", + "tree": "\ue92a", + "triangle": "\ue92b", "triangle_outline": "\ue900" -} +} \ No newline at end of file diff --git a/construct/ui/resources/icons/alert.svg b/construct/ui/resources/icons/alert.svg index 3d9ac03..ecf81b1 100644 --- a/construct/ui/resources/icons/alert.svg +++ b/construct/ui/resources/icons/alert.svg @@ -1,3 +1,3 @@ - + diff --git a/construct/ui/resources/icons/arrow_down.svg b/construct/ui/resources/icons/arrow_down.svg index 76f1373..b5567d4 100644 --- a/construct/ui/resources/icons/arrow_down.svg +++ b/construct/ui/resources/icons/arrow_down.svg @@ -1,3 +1,3 @@ - + diff --git a/construct/ui/resources/icons/arrow_down_solid.svg b/construct/ui/resources/icons/arrow_down_solid.svg index 6649c66..7efc424 100644 --- a/construct/ui/resources/icons/arrow_down_solid.svg +++ b/construct/ui/resources/icons/arrow_down_solid.svg @@ -1,3 +1,3 @@ - + diff --git a/construct/ui/resources/icons/arrow_left.svg b/construct/ui/resources/icons/arrow_left.svg index 3ebded7..b8024ea 100644 --- a/construct/ui/resources/icons/arrow_left.svg +++ b/construct/ui/resources/icons/arrow_left.svg @@ -1,3 +1,3 @@ - + diff --git a/construct/ui/resources/icons/arrow_left_solid.svg b/construct/ui/resources/icons/arrow_left_solid.svg index 9b536f6..620248b 100644 --- a/construct/ui/resources/icons/arrow_left_solid.svg +++ b/construct/ui/resources/icons/arrow_left_solid.svg @@ -1,3 +1,3 @@ - + diff --git a/construct/ui/resources/icons/arrow_right.svg b/construct/ui/resources/icons/arrow_right.svg index aafa040..11054d2 100644 --- a/construct/ui/resources/icons/arrow_right.svg +++ b/construct/ui/resources/icons/arrow_right.svg @@ -1,3 +1,3 @@ - + diff --git a/construct/ui/resources/icons/arrow_right_solid.svg b/construct/ui/resources/icons/arrow_right_solid.svg index 1f6bc3f..8927ee6 100644 --- a/construct/ui/resources/icons/arrow_right_solid.svg +++ b/construct/ui/resources/icons/arrow_right_solid.svg @@ -1,3 +1,3 @@ - + diff --git a/construct/ui/resources/icons/arrow_up.svg b/construct/ui/resources/icons/arrow_up.svg index c41feb8..5aed614 100644 --- a/construct/ui/resources/icons/arrow_up.svg +++ b/construct/ui/resources/icons/arrow_up.svg @@ -1,3 +1,3 @@ - + diff --git a/construct/ui/resources/icons/arrow_up_solid.svg b/construct/ui/resources/icons/arrow_up_solid.svg index fadb455..e685c3c 100644 --- a/construct/ui/resources/icons/arrow_up_solid.svg +++ b/construct/ui/resources/icons/arrow_up_solid.svg @@ -1,3 +1,3 @@ - + diff --git a/construct/ui/resources/icons/back.svg b/construct/ui/resources/icons/back.svg index 3fde354..239250b 100644 --- a/construct/ui/resources/icons/back.svg +++ b/construct/ui/resources/icons/back.svg @@ -1,3 +1,3 @@ - + diff --git a/construct/ui/resources/icons/bookmark.svg b/construct/ui/resources/icons/bookmark.svg index ffd5c5f..046a326 100644 --- a/construct/ui/resources/icons/bookmark.svg +++ b/construct/ui/resources/icons/bookmark.svg @@ -1,3 +1,3 @@ - + diff --git a/construct/ui/resources/icons/bookmark_outline.svg b/construct/ui/resources/icons/bookmark_outline.svg index 4df48c2..ccd6864 100644 --- a/construct/ui/resources/icons/bookmark_outline.svg +++ b/construct/ui/resources/icons/bookmark_outline.svg @@ -1,7 +1,3 @@ - - - - - + diff --git a/construct/ui/resources/icons/check.svg b/construct/ui/resources/icons/check.svg index 01d8a6d..42a3e5a 100644 --- a/construct/ui/resources/icons/check.svg +++ b/construct/ui/resources/icons/check.svg @@ -1,3 +1,3 @@ - + diff --git a/construct/ui/resources/icons/checkbox_check.svg b/construct/ui/resources/icons/checkbox_check.svg index 2576240..c93c368 100644 --- a/construct/ui/resources/icons/checkbox_check.svg +++ b/construct/ui/resources/icons/checkbox_check.svg @@ -1,4 +1,4 @@ - - + + diff --git a/construct/ui/resources/icons/checkbox_off.svg b/construct/ui/resources/icons/checkbox_off.svg index 80e5df8..c7d5ece 100644 --- a/construct/ui/resources/icons/checkbox_off.svg +++ b/construct/ui/resources/icons/checkbox_off.svg @@ -1,3 +1,3 @@ - + diff --git a/construct/ui/resources/icons/checkbox_on.svg b/construct/ui/resources/icons/checkbox_on.svg index 6086516..37bfb1c 100644 --- a/construct/ui/resources/icons/checkbox_on.svg +++ b/construct/ui/resources/icons/checkbox_on.svg @@ -1,4 +1,4 @@ - - + + diff --git a/construct/ui/resources/icons/circle.svg b/construct/ui/resources/icons/circle.svg index 60df7b4..4ff2a2c 100644 --- a/construct/ui/resources/icons/circle.svg +++ b/construct/ui/resources/icons/circle.svg @@ -1,3 +1,3 @@ - + diff --git a/construct/ui/resources/icons/circle_outline.svg b/construct/ui/resources/icons/circle_outline.svg index 1d6a780..5cf965c 100644 --- a/construct/ui/resources/icons/circle_outline.svg +++ b/construct/ui/resources/icons/circle_outline.svg @@ -1,3 +1,3 @@ - + diff --git a/construct/ui/resources/icons/close.svg b/construct/ui/resources/icons/close.svg index b3b7686..e8060c6 100644 --- a/construct/ui/resources/icons/close.svg +++ b/construct/ui/resources/icons/close.svg @@ -1,3 +1,3 @@ - + diff --git a/construct/ui/resources/icons/collection.svg b/construct/ui/resources/icons/collection.svg index de8fe2d..9ec15b0 100644 --- a/construct/ui/resources/icons/collection.svg +++ b/construct/ui/resources/icons/collection.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/construct/ui/resources/icons/construct.svg b/construct/ui/resources/icons/construct.svg index 21c575f..4affd0f 100644 --- a/construct/ui/resources/icons/construct.svg +++ b/construct/ui/resources/icons/construct.svg @@ -1,9 +1,9 @@ - - - - - - - + + + + + + + diff --git a/construct/ui/resources/icons/edit.svg b/construct/ui/resources/icons/edit.svg new file mode 100644 index 0000000..0fb282a --- /dev/null +++ b/construct/ui/resources/icons/edit.svg @@ -0,0 +1,3 @@ + + + diff --git a/construct/ui/resources/icons/error.svg b/construct/ui/resources/icons/error.svg index d7b975d..c1a742a 100644 --- a/construct/ui/resources/icons/error.svg +++ b/construct/ui/resources/icons/error.svg @@ -1,3 +1,3 @@ - + diff --git a/construct/ui/resources/icons/filter.svg b/construct/ui/resources/icons/filter.svg index 935a7c0..1592abd 100644 --- a/construct/ui/resources/icons/filter.svg +++ b/construct/ui/resources/icons/filter.svg @@ -1,3 +1,3 @@ - + diff --git a/construct/ui/resources/icons/folder.svg b/construct/ui/resources/icons/folder.svg index 2ee6bbf..c491a10 100644 --- a/construct/ui/resources/icons/folder.svg +++ b/construct/ui/resources/icons/folder.svg @@ -1,3 +1,3 @@ - + diff --git a/construct/ui/resources/icons/folder_open.svg b/construct/ui/resources/icons/folder_open.svg index 3873893..bca11f2 100644 --- a/construct/ui/resources/icons/folder_open.svg +++ b/construct/ui/resources/icons/folder_open.svg @@ -1,4 +1,4 @@ - - + + diff --git a/construct/ui/resources/icons/forward.svg b/construct/ui/resources/icons/forward.svg index 5795e9e..5c3700d 100644 --- a/construct/ui/resources/icons/forward.svg +++ b/construct/ui/resources/icons/forward.svg @@ -1,3 +1,3 @@ - + diff --git a/construct/ui/resources/icons/grid.svg b/construct/ui/resources/icons/grid.svg index 911a587..e725def 100644 --- a/construct/ui/resources/icons/grid.svg +++ b/construct/ui/resources/icons/grid.svg @@ -1,6 +1,6 @@ - - - - + + + + diff --git a/construct/ui/resources/icons/group_list.svg b/construct/ui/resources/icons/group_list.svg index 6bf1426..8366305 100644 --- a/construct/ui/resources/icons/group_list.svg +++ b/construct/ui/resources/icons/group_list.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/construct/ui/resources/icons/home.svg b/construct/ui/resources/icons/home.svg index 7ae1a77..b421da1 100644 --- a/construct/ui/resources/icons/home.svg +++ b/construct/ui/resources/icons/home.svg @@ -1,3 +1,3 @@ - + diff --git a/construct/ui/resources/icons/list.svg b/construct/ui/resources/icons/list.svg index 0fcbecd..0a791d0 100644 --- a/construct/ui/resources/icons/list.svg +++ b/construct/ui/resources/icons/list.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/construct/ui/resources/icons/menu.svg b/construct/ui/resources/icons/menu.svg index da0a8a2..ad3934d 100644 --- a/construct/ui/resources/icons/menu.svg +++ b/construct/ui/resources/icons/menu.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/construct/ui/resources/icons/menu_dots.svg b/construct/ui/resources/icons/menu_dots.svg index ac64275..a007a82 100644 --- a/construct/ui/resources/icons/menu_dots.svg +++ b/construct/ui/resources/icons/menu_dots.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/construct/ui/resources/icons/minus.svg b/construct/ui/resources/icons/minus.svg index 4ebeec0..3c34bb4 100644 --- a/construct/ui/resources/icons/minus.svg +++ b/construct/ui/resources/icons/minus.svg @@ -1,3 +1,3 @@ - + diff --git a/construct/ui/resources/icons/plus.svg b/construct/ui/resources/icons/plus.svg index 0811c01..9f838ce 100644 --- a/construct/ui/resources/icons/plus.svg +++ b/construct/ui/resources/icons/plus.svg @@ -1,3 +1,3 @@ - + diff --git a/construct/ui/resources/icons/question.svg b/construct/ui/resources/icons/question.svg index 32232f6..05d0804 100644 --- a/construct/ui/resources/icons/question.svg +++ b/construct/ui/resources/icons/question.svg @@ -1,4 +1,4 @@ - - + + diff --git a/construct/ui/resources/icons/refresh.svg b/construct/ui/resources/icons/refresh.svg index 5c65043..3fbc2f2 100644 --- a/construct/ui/resources/icons/refresh.svg +++ b/construct/ui/resources/icons/refresh.svg @@ -1,4 +1,4 @@ - - + + diff --git a/construct/ui/resources/icons/search.svg b/construct/ui/resources/icons/search.svg index 85019af..8f9e8b3 100644 --- a/construct/ui/resources/icons/search.svg +++ b/construct/ui/resources/icons/search.svg @@ -1,3 +1,3 @@ - + diff --git a/construct/ui/resources/icons/settings.svg b/construct/ui/resources/icons/settings.svg index 215c6e4..d131a80 100644 --- a/construct/ui/resources/icons/settings.svg +++ b/construct/ui/resources/icons/settings.svg @@ -1,3 +1,3 @@ - + diff --git a/construct/ui/resources/icons/sort.svg b/construct/ui/resources/icons/sort.svg index 1e9fb7d..62d744e 100644 --- a/construct/ui/resources/icons/sort.svg +++ b/construct/ui/resources/icons/sort.svg @@ -1,4 +1,4 @@ - - + + diff --git a/construct/ui/resources/icons/square.svg b/construct/ui/resources/icons/square.svg index eca7f44..a1ca4ad 100644 --- a/construct/ui/resources/icons/square.svg +++ b/construct/ui/resources/icons/square.svg @@ -1,3 +1,3 @@ - + diff --git a/construct/ui/resources/icons/square_outline.svg b/construct/ui/resources/icons/square_outline.svg index 80e5df8..c7d5ece 100644 --- a/construct/ui/resources/icons/square_outline.svg +++ b/construct/ui/resources/icons/square_outline.svg @@ -1,3 +1,3 @@ - + diff --git a/construct/ui/resources/icons/tree.svg b/construct/ui/resources/icons/tree.svg index 0d4eb68..b7539f6 100644 --- a/construct/ui/resources/icons/tree.svg +++ b/construct/ui/resources/icons/tree.svg @@ -1,3 +1,3 @@ - + diff --git a/construct/ui/resources/icons/triangle.svg b/construct/ui/resources/icons/triangle.svg index e8bc4f2..c227f42 100644 --- a/construct/ui/resources/icons/triangle.svg +++ b/construct/ui/resources/icons/triangle.svg @@ -1,3 +1,3 @@ - + diff --git a/construct/ui/resources/icons/triangle_outline.svg b/construct/ui/resources/icons/triangle_outline.svg index d73275d..6f9c8c7 100644 --- a/construct/ui/resources/icons/triangle_outline.svg +++ b/construct/ui/resources/icons/triangle_outline.svg @@ -1,3 +1,3 @@ - + diff --git a/construct/ui/resources/styles/_base.scss b/construct/ui/resources/styles/_base.scss new file mode 100644 index 0000000..30b53cd --- /dev/null +++ b/construct/ui/resources/styles/_base.scss @@ -0,0 +1,403 @@ +// Base theme +// A Mixin used to generate subthemes +@mixin base( + $primary: #CC4455, + $on_primary: #000000, + $surface: #FFFFFF, + $on_surface: #000000, + $bg: #000000, + $on_bg: #FFFFFF, +) { + + + // Base Style // + + + background: $surface; + + * { + color: $on_surface; + } + + QFrame#line { + background: none; + color: $bg; + border: $border_style $bg; + } + + // Menu Style // + + QMenu { + background-color: $surface; + border: $border-style $bg; + menu-scrollable: 1; + + &::icon { + padding: 8px 8px 8px 8px; + min-width: 24px + } + + &::item { + padding: 8px 8px 8px 24px; + background-color: $transparent; + min-width: 144px; + } + + &::item:selected { + background-color: fade-out($primary, 0.55); + border: $border-style fade-out($primary, 0.55); + } + + &::item:pressed { + color: $on_primary; + background-color: $primary; + } + } + + + // Icons and Button Styles // + + #icon { + outline: none; + border: 0; + padding: 0; + margin: 0; + } + + QPushButton[type="text"] { + padding: 8px 16px 8px 16px; + border: $border-style vary($surface, 10%); + border-radius: $border-radius; + + &:disabled { + color: vary($surface, 15%); + } + &:hover { + color: $on_primary; + background-color: set_alpha($primary, 0.45); + border: $border-style $primary; + } + &:focus { + outline: none; + border: $border-style $primary; + } + &:pressed, &:checked { + color: $on_primary; + background-color: $primary; + border: $border-style $primary; + } + } + + QPushButton[type="icon"] { + outline: none; + padding: 0; + margin: 0; + border: 0; + border-radius: $border-radius; + + &:disabled { + color: vary($surface, 15%); + } + &:hover { + background-color: set_alpha($primary, 0.45); + border: $border-style $primary; + } + &:focus { + outline: none; + border: $border-style $primary; + } + &:pressed, &:checked { + background-color: $primary; + border: $border-style $primary; + } + } + + // Controls // + + QLineEdit { + border: $border-style $bg; + border-radius: 1px; + background: $surface; + color: $on_surface; + padding: 4px 8px 4px 8px; + + &:focus { + border: $border-style $primary; + border-radius: $border-radius; + background: $surface; + color: $on_surface; + padding: 0px 8px 0px 8px; + } + } + + + + QListView, + QTreeView { + background: $transparent; + border: 0; + outline: none; + padding: 0; + show-decoration-selected: 1; + + &:disabled { + border: 0; + color: vary($surface, 15%); + } + + &::item { + padding: 6px 0px 6px 0px; + background-color: $transparent; + } + &::branch { + padding: 0; + } + &::item:pressed, + &::item:selected, + &::branch:selected { + color: $on_primary; + background-color: $primary; + } + &::item:!selected:hover, + &::branch:!selected:hover { + color: $on_primary; + background-color: set_alpha($primary, 0.55); + border-top: $border-style $primary; + border-bottom: $border-style $primary; + } + &::branch:closed:has-children:adjoins-item { + border-image: none; + image: resource("icons/folder.svg"); + } + &::branch:open:has-children:adjoins-item { + border-image: none; + image: resource("icons/folder_open.svg"); + } + } + + QScrollBar { + + &:vertical { + border: 0; + background: $bg; + width: 8px; + padding: 2px; + margin: 8px 0px 8px 0px; + } + &::handle:vertical { + background: set_alpha($on_bg, 0.75); + border: 0; + border-radius: 2px; + } + &::handle:vertical:hover { + background: $on_bg; + border: 0; + border-radius: 2px; + } + &::add-line:vertical { + border: 0; + background: $bg; + height: 8px; + subcontrol-position: bottom; + subcontrol-origin: margin; + } + + &::sub-line:vertical { + border: 0; + background: $bg; + height: 8px; + subcontrol-position: top; + subcontrol-origin: margin; + } + &::up-arrow:vertical {} + + &::down-arrow:vertical {} + + &::add-page:vertical, &::sub-page:vertical { + background: none; + } + + &:horizontal { + border: 0; + background: $bg; + height: 8px; + padding: 2px; + margin: 0px 8px 0px 8px; + } + &::handle:horizontal { + background: set_alpha($on_bg, 0.75); + border: 0; + border-radius: 2px; + } + &::handle:horizontal:hover { + background: $on_bg; + border: 0; + border-radius: 2px; + } + &::add-line:horizontal { + border: 0; + background: $bg; + width: 8px; + subcontrol-position: right; + subcontrol-origin: margin; + } + + &::sub-line:horizontal { + border: 0; + background: $bg; + width: 8px; + subcontrol-position: left; + subcontrol-origin: margin; + } + &::left-arrow:horizontal {} + + &::right-arrow:horizontal {} + + &::add-page:horizontal, &::sub-page:horizontal { + background: none; + } + } + + QSplitter { + + &::handle { + subcontrol-position: top; + border-left: $border-style $bg; + background: $transparent; + image: resource('icons/arrow_right.svg'); + } + + &::handle:horizontal { + width: 14px; + } + + &::handle:vertical { + height: 14px; + } + + &::handle:pressed { + image: resource('icons/arrow_right_solid.svg'); + } + } + + + // Navigation crumbs // + + + #crumbs { + margin: 0; + padding: 0; + } + + #crumb { + margin: 0; + padding: 0; + border-radius: 1; + + &:hover { + color: $on_primary; + background-color: set_alpha($primary, 0.65); + } + + QPushButton { + margin: 0; + padding: 0px 8px 0px 8px; + border-radius: 1px; + + &:disabled { + color: vary($surface, 15%); + } + &:hover { + color: $on_primary; + border: $border-style $primary; + } + &:focus { + outline: none; + border: $border-style $primary; + } + &:pressed { + color: $on_primary; + background-color: $primary; + } + &::menu-indicator { + image: resource("icons/arrow_right_solid.svg"); + width: 12px; + height: 12px; + subcontrol-origin: padding; + subcontrol-position: center; + } + + &::menu-indicator:pressed, + &::menu-indicator:open { + image: resource("icons/arrow_down_solid.svg"); + width: 12px; + height: 12px; + subcontrol-origin: padding; + subcontrol-position: center; + } + } + } + + #bookmarks { + QPushButton { + padding: 6px 8px 6px 8px; + border: 0; + border-radius: $border-radius; + + &:disabled { + border: 0; + color: vary($surface, 15%); + } + &:hover { + color: $on_primary; + background-color: set_alpha($primary, 0.45); + border: $border-style $primary; + } + &:focus { + outline: none; + border: $border-style $primary; + } + &:pressed { + color: $on_primary; + background-color: $primary; + border: $border-style $primary; + } + } + + QListView::item { + padding: 6px 8px 6px 8px; + } + } + + #SidebarTabs { + background: $bg; + + QPushButton { + padding: 8px 16px 8px 16px; + border: 0; + color: vary($on_surface, 15%); + border-top-left-radius: 1px; + border-top-right-radius: 1px; + border-bottom-right-radius: 0px; + border-bottom-left-radius: 0px; + + &:disabled { + color: vary($surface, 15%); + } + &:hover { + color: $on_surface; + background-color: vary($bg, 5%); + } + &:focus { + outline: none; + border: $border-style $primary; + } + &:pressed, &:checked { + color: $on_surface; + background-color: $surface; + border: 0; + } + } + } + +} diff --git a/construct/ui/resources/styles/_defaults.scss b/construct/ui/resources/styles/_defaults.scss new file mode 100644 index 0000000..d5ca1af --- /dev/null +++ b/construct/ui/resources/styles/_defaults.scss @@ -0,0 +1,23 @@ +// Fonts +$font_stack: Roboto !default; +$font_size: 12px !default; +$font_weight: 400 !default; + +// Colors +$primary: #FF5862 !default; +$primary_variant: #D72F57 !default; +$on_primary: #FFFFFF !default; +$alert: #FACE49 !default; +$error: #F15555 !default; +$success: #7EDC9E !default; +$info: #87CDEB !default; +$on_secondary: #000000 !default; +$background: #22222D !default; +$on_background: #F1F1F3 !default; +$surface: #414149 !default; +$on_surface: #F1F1F3 !default; +$transparent: rgba(0, 0, 0, 0) !default; + +// Borders +$border-style: 1px solid; +$border-radius: 1px; diff --git a/construct/ui/resources/styles/_funcs.scss b/construct/ui/resources/styles/_funcs.scss index dd66aef..e19aa72 100644 --- a/construct/ui/resources/styles/_funcs.scss +++ b/construct/ui/resources/styles/_funcs.scss @@ -10,6 +10,11 @@ } +@function set_alpha($color, $opacity) { + @return rgba(red($color), green($color), blue($color), $opacity); +} + + @function brightness($color) { @return ((red($color) + green($color) + blue($color)) / 3) / 255; } diff --git a/construct/ui/resources/styles/theme.scss b/construct/ui/resources/styles/theme.scss index b6790ec..f909884 100644 --- a/construct/ui/resources/styles/theme.scss +++ b/construct/ui/resources/styles/theme.scss @@ -1,46 +1,22 @@ -// Fonts -$font_stack: Roboto !default; -$font_size: 12pt !default; -$font_weight: 400 !default; - -// Colors -$primary: #FF5862 !default; -$primary_variant: #D72F57 !default; -$on_primary: #FFFFFF !default; -$alert: #FACE49 !default; -$error: #F15555 !default; -$success: #7EDC9E !default; -$info: #87CDEB !default; -$on_secondary: #000000 !default; -$background: #33333D !default; -$on_background: #F1F1F3 !default; -$surface: #4F4F59 !default; -$on_surface: #F1F1F3 !default; -$transparent: rgba(0, 0, 0, 0); - - +@import 'defaults'; @import 'funcs'; +@import 'base'; // Mixins // - - @mixin roboto { font-family: $font_stack; font-weight: $font_weight; - font-size: $font_size; } @mixin roboto-light { font-family: $font_stack; font-weight: $font_weight - 100; - font-size: $font_size; } @mixin roboto-medium { font-family: $font_stack; font-weight: $font_weight + 100; - font-size: $font_size; } @mixin icon { @@ -57,6 +33,7 @@ $transparent: rgba(0, 0, 0, 0); QWidget { @include roboto; + font-size: $font-size; } *[status="alert"] { @@ -86,116 +63,39 @@ QWidget { #h1 { @include roboto; - font-size: 24pt; + font-size: 24px; } #h2 { @include roboto-medium; - font-size: 20pt; + font-size: 20px; } #h3 { @include roboto; - font-size: 18pt; + font-size: 18px; } #h4 { @include roboto; - font-size: 14pt; + font-size: 14px; } #h5 { @include roboto-medium; - font-size: 14pt; + font-size: 14px; } #p { @include roboto; - font-size: 12pt; -} - - -// Base theme -@mixin base( - $primary: #CC4455, - $on_primary: #000000, - $bg: #FFFFFF, - $on_bg: #000000 -) { - - background: $bg; - - * { - color: $on_bg; - } - - #icon { - outline: none; - border: 2 solid vary($bg, 5%); - border-radius: 3; - } - #icon:focus { - border: 2 solid vary($bg, 15%); - } - #icon:disabled { - border: 0; - } - #icon:hover { - background-color: vary($bg, 5%); - } - #icon:pressed { - background-color: vary($bg, 15%); - } - - QPushButton#text-button { - padding: scale_pt(8 24 8 24); - border: 2 solid vary($bg, 10%); - border-radius: 3; - } - QPushButton#text-button:disabled { - color: vary($bg, 15%); - } - QPushButton#text-button:hover { - color: $on_primary; - background-color: fade-out($primary, 0.55); - border: 2 solid fade-out($primary, 0.55); - } - QPushButton#text-button:focus { - outline: none; - border: 2 solid fade-out($primary, 0.25); - } - QPushButton#text-button:pressed { - color: $on_primary; - background-color: $primary; - } - QPushButton#tool-button { - padding: scale_pt(8 24 8 24); - border-radius: 3; - font-size: 8pt; - } - QPushButton#tool-button:disabled { - color: vary($bg, 15%); - } - QPushButton#tool-button:hover { - color: $on_primary; - background-color: fade-out($primary, 0.55); - border: 2 solid fade-out($primary, 0.55); - } - QPushButton#tool-button:focus { - outline: none; - border: 2 solid fade-out($primary, 0.25); - } - QPushButton#tool-button:pressed { - color: $on_primary; - background-color: $primary; - } + font-size: 12px; } // Primary colored sub-theme // -QWidget#primary { +QWidget[theme="primary"] { @include base( $primary_variant, $on_primary, @@ -204,35 +104,37 @@ QWidget#primary { ); } -QDialog#primary { - border: 2 solid vary($primary, 20%); +QDialog[theme="primary"] { + border: $border-style vary($primary, 20%); } // Surface colored sub-theme // -QWidget#surface { +QWidget[theme="surface"] { @include base( $primary, $on_primary, $surface, $on_surface, + $background, + $on_background, ); } -QDialog#surface { - border: 2 solid vary($surface, 10%); +QDialog[theme="surface"] { + border: $border-style $background; } -QDialog#surface:active { - border: 2 solid vary($surface, 20%); +QDialog[theme="surface"]:active { + border: $border-style darken($background, 4); } // Background colored sub-theme // -QWidget#background { +QWidget[theme="background"] { @include base( $primary, $on_primary, @@ -241,91 +143,91 @@ QWidget#background { ); } -QDialog#background { - border: 2 solid vary($background, 10%); +QDialog[theme="background"] { + border: $border-style vary($background, 10%); } -QDialog#background:active { - border: 2 solid vary($background, 20%); +QDialog[theme="background"]:active { + border: $border-style vary($background, 20%); } // Alert colored sub-theme // -QWidget#alert { +QWidget[theme="alert"] { @include base( - $alert, + vary($alert, 20%), $on_secondary, $alert, $on_secondary, ); } -QDialog#alert { - border: 2 solid vary($alert, 10%); +QDialog[theme="alert"] { + border: $border-style vary($alert, 10%); } -QDialog#alert:active { - border: 2 solid vary($alert, 20%); +QDialog[theme="alert"]:active { + border: $border-style vary($alert, 20%); } // Error colored sub-theme // -QWidget#error { +QWidget[theme="error"] { @include base( + vary($error, 20%), $on_secondary, $error, - $error, $on_secondary, ); } -QDialog#error { - border: 2 solid vary($error, 10%); +QDialog[theme="error"] { + border: $border-style vary($error, 10%); } -QDialog#error:active { - border: 2 solid vary($error, 20%); +QDialog[theme="error"]:active { + border: $border-style vary($error, 20%); } // Success colored sub-theme // -QWidget#success { +QWidget[theme="success"] { @include base( + vary($success, 20%), $on_secondary, $success, - $success, $on_secondary, ); } -QDialog#success { - border: 2 solid vary($success, 10%); +QDialog[theme="success"] { + border: $border-style vary($success, 10%); } -QDialog#success:active { - border: 2 solid vary($success, 20%); +QDialog[theme="success"]:active { + border: $border-style vary($success, 20%); } // Info colored sub-theme // -QWidget#info { +QWidget[theme="info"] { @include base( + vary($info, 20%), $on_secondary, $info, - $info, $on_secondary, ); } -QDialog#info { - border: 2 solid vary($info, 10%); +QDialog[theme="info"] { + border: $border-style vary($info, 10%); } -QDialog#info:active { - border: 2 solid vary($info, 20%); +QDialog[theme="info"]:active { + border: $border-style vary($info, 20%); } diff --git a/construct/ui/scale.py b/construct/ui/scale.py index 972ea26..c636cf1 100644 --- a/construct/ui/scale.py +++ b/construct/ui/scale.py @@ -20,13 +20,19 @@ def factor(): return dpi() / 96.0 -def px(value): +def px(*values): '''Scale a pixel value based on screen dpi.''' - return int(factor() * value) + if len(values) == 1: + return int(factor() * values[0]) + return [int(factor() * value) for value in values] -def pt(value): + +def pt(*values): '''Scale a point value based on screen dpi.''' - return int(factor() * value * 1.33) + if len(values) == 1: + return int(factor() * values[0] * 1.33) + + return [int(factor() * value * 1.33) for value in values] diff --git a/construct/ui/state.py b/construct/ui/state.py new file mode 100644 index 0000000..e19bb65 --- /dev/null +++ b/construct/ui/state.py @@ -0,0 +1,270 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import + +# Standard library imports +import contextlib +from copy import deepcopy + +# Third party imports +from Qt import QtCore + +# Local imports +from ..compat import Mapping, Sequence, basestring + + +missing = object() + + +def value_factory(name, default, parent): + + value_map = [ + (basestring, Value), + (Sequence, SequenceValue), + (Mapping, MappingValue), + ] + + for types, value_type in value_map: + if isinstance(default, types): + return value_type(name, default, parent) + + return Value(name, default, parent) + + +class Value(QtCore.QObject): + '''Base Value type. + + All Value types should emit the changed signal when their internal value + changes. + ''' + + changed = QtCore.Signal((object,)) + + def __init__(self, name, default, parent): + self.name = name + self.value = default + super(Value, self).__init__(parent=parent) + + def get(self): + return self.value + + def set(self, value): + old_value = self.value + self.value = value + if value != old_value: + self.changed.emit(value) + + def copy(self): + if hasattr(self.value, 'copy'): + return self.value.copy() + else: + return deepcopy(self.value) + + +class SequenceValue(Value): + '''Value type that handles Sequence Types like list, tuple, and deque.''' + + def __getitem__(self, index): + return self.value[index] + + def __setitem__(self, index, value): + self.value[index] = value + self.changed.emit(self.value) + + def __delitem__(self, index): + del self.value[index] + self.changed.emit(self.value) + + def __iter__(self): + return iter(self.value) + + def __len__(self): + return len(self.value) + + def extend(self, value): + self.value.extend(value) + self.changed.emit(self.value) + + def extendleft(self, value): + try: + self.value.extendleft(value) + except AttributeError: + value.extend(self.value) + self.value = value + self.changed.emit(self.value) + + def remove(self, value): + self.value.remove(value) + self.changed.emit(self.value) + + def popleft(self, value): + try: + value = self.value.popleft(value) + except AttributeError: + value = self.value[0] + self.value = self.value[1:] + self.changed.emit(self.value) + return value + + def pop(self): + if not len(self.value): + return + value = self.value.pop() + self.changed.emit(self.value) + return value + + def appendleft(self, value): + try: + self.value.appendleft(value) + except AttributeError: + self.value.insert(0, value) + self.changed.emit(self.value) + + def append(self, value): + self.value.append(value) + self.changed.emit(self.value) + + def sort(self, key=None): + old_value = self.value[:] + self.value.sort(key=key) + if self.value != old_value: + self.changed.emit(self.value) + + +class MappingValue(Value): + '''Value type that handles Mapping types like Dict.''' + + def __getitem__(self, key): + return self.value[key] + + def __setitem__(self, key, value): + self.value[key] = value + self.changed.emit(self.value) + + def __delitem__(self, key): + del self.value[key] + self.changed.emit(self.value) + + def __iter__(self): + return iter(self.value) + + def __len__(self): + return len(self.value) + + def __contains__(self, key): + return key in self.value + + def __eq__(self, other): + return self.value == other + + def __ne__(self, other): + return self.value != other + + def set(self, *args): + if len(args) == 1: + self.value = args[0] + if len(args) == 2: + self.value[args[0]] = args[1] + self.changed.emit(self.value) + + def get(self, key=missing, default=missing): + if key is missing: + return self.value + if key not in self.value: + if default is not missing: + return default + raise KeyError(key) + return self.value[key] + + def popitem(self, *args): + item = self.value.popitem(*args) + self.changed.emit(self.value) + return item + + def pop(self, *args): + value = self.value.pop(*args) + self.changed.emit(self.value) + return value + + def update(self, *args, **kwargs): + self.value.update(*args, **kwargs) + self.changed.emit(self.value) + + def setdefault(self, *args, **kwargs): + value = self.value.setdefault(*args, **kwargs) + self.changed.emit(self.value) + return value + + def items(self): + return self.value.items() + + def keys(self): + return self.value.keys() + + def values(self): + return self.value.values() + + +class State(QtCore.QObject): + + changed = QtCore.Signal((str, object)) + + def __init__(self, **kwargs): + super(State, self).__init__(kwargs.pop('parent', None)) + self._signals_blocked = False + + self._data = {} + for k, v in kwargs.items(): + self[k] = v + + def update(self, **kwargs): + for k, v in kwargs.items(): + self.__setitem__(k, v) + + def get(self, key): + return self.__getitem__(key) + + def set(self, key, value): + self.__setitem__(key, value) + + def __getitem__(self, key): + return self._data[key] + + def __setitem__(self, key, value): + if key not in self._data: + value = value_factory(key, value, self) + value.changed.connect(self._change_emitter(key)) + self._data[key] = value + else: + obj = self._data[key] + previous_value = obj.get() + if previous_value != value: + obj.set(value) + + def __delitem__(self, key): + del self._data[key] + + def __iter__(self): + return iter(self._data) + + def __len__(self): + return len(self._data) + + def _change_emitter(self, name): + def emit_changed(value): + self.changed.emit(name, value) + return emit_changed + + def block_signals(self, value): + self._signals_blocked = value + self.blockSignals(value) + for v in self._data.values(): + v.blockSignals(value) + + @contextlib.contextmanager + def signals_blocked(self): + self.block_signals(True) + try: + yield + finally: + self.block_signals(False) diff --git a/construct/ui/theme.py b/construct/ui/theme.py index 9d2b3d2..e2a0ef3 100644 --- a/construct/ui/theme.py +++ b/construct/ui/theme.py @@ -4,15 +4,16 @@ # Standard library imports import logging -from functools import wraps +import re # Third party imports import qtsass # Local imports -from ..compat import basestring +from ..compat import basestring, wraps from ..types import WeakSet -from . import resources, scale +from . import resources +from .scale import pt, px _log = logging.getLogger(__name__) @@ -131,21 +132,6 @@ class Theme(object): defaults = dict( name='default', - font_stack='Roboto', - font_size='12pt', - font_weight='400', - primary='#FF5862', - primary_variant='#D72F57', - on_primary='#FFFFFF', - alert='#FACE49', - error='#F15555', - success='#7EDC9E', - info='#87CDEB', - on_secondary='#000000', - background='#33333D', - on_background='#F1F1F3', - surface='#4F4F59', - on_surface='#F1F1F3', ) color_variables = [ 'primary', @@ -215,7 +201,9 @@ def compile_stylesheet(self): var_tmpl = '${}: {};\n' rgb_tmpl = '${}: rgb({}, {}, {});\n' rgba_tmpl = '${}: rgba({}, {}, {}, {});\n' - for var in self.variables: + for var in self.color_variables: + if not hasattr(self, var): + continue value = getattr(self, var) if isinstance(value, basestring): sass += var_tmpl.format(var, value) @@ -243,11 +231,17 @@ def compile_stylesheet(self): str(self.resources.path / 'styles'), ], custom_functions={ - 'res_url': sass_res_url(self), - 'scale_pt': sass_scale_pt(self), - 'scale_px': sass_scale_px(self), + 'resource': sass_resource(self), }, ) + + # Scale pixel values + def dpiaware_scale(value): + value, unit = int(value.group(1)), value.group(2) + return str(px(value)) + unit + + css = re.sub(r'(\d+)(px)', dpiaware_scale, css) + return css def refresh_stylesheet(self): @@ -294,6 +288,8 @@ def set_color(self, name, value): else: raise ValueError('Expected rgb tuple or hex code got %s' % value) + self.refresh_stylesheet() + def hex(self, name): '''Return the named color as a hex code string.''' @@ -316,12 +312,18 @@ def pixmap(self, resource, size=None, family=None): Arguments: resource (str): Path relative to Construct PATH or font icon name - size (QSize): Size of pixmap to return + size (tuple): Size of pixmap to return family (str): Font family for font icon character (optional) ''' + from Qt import QtCore from Qt.QtGui import QPixmap, QIcon - from .icons import FontIcon + from .widgets import FontIcon + + if size: + size = QtCore.QSize(*px(*size)) + else: + size = QtCore.QSize(*px(24, 24)) path = self.resources.get(resource, None) if path: @@ -346,7 +348,7 @@ def icon(self, resource, family=None, parent=None): ''' from Qt.QtGui import QIcon - from .icons import SvgIcon, FontIcon + from .widgets import SvgIcon, FontIcon path = self.resources.get(resource, None) if path: @@ -363,15 +365,15 @@ def icon(self, resource, family=None, parent=None): # Sass functions -def sass_res_url(theme): +def sass_resource(theme): '''Get an url for a construct resource. Usage: - QPushButton {qproperty-icon: res_url(icons/plus.svg);} + QPushButton {qproperty-icon: resource(icons/plus.svg);} ''' - def res_url(resource): + def resolve_resource(resource): return 'url("%s")' % theme.resources.get(resource).as_posix() - return res_url + return resolve_resource def sass_scale_pt(theme): @@ -383,17 +385,17 @@ def sass_scale_pt(theme): ''' def scale_pt(value): if isinstance(value, float): - return str(scale.pt(value)) + return str(pt(value)) # Handle sass types import sass if isinstance(value, sass.SassNumber): - return str(scale.pt(value.value)) + return str(pt(value.value)) + value.unit if isinstance(value, sass.SassList): result = [] for item in value.items: - result.append(str(scale.pt(item.value))) + result.append(str(pt(item.value)) + item.unit) return ' '.join(result) return scale_pt @@ -408,17 +410,17 @@ def sass_scale_px(theme): def scale_px(value): if isinstance(value, float): - return str(scale.px(value)) + return str(px(value)) # Handle sass types import sass if isinstance(value, sass.SassNumber): - return str(scale.px(value.value)) + return str(px(value.value)) + value.unit if isinstance(value, sass.SassList): result = [] for item in value.items: - result.append(str(scale.px(item.value))) + result.append(str(px(item.value)) + item.unit) return ' '.join(result) return scale_px diff --git a/construct/ui/widgets.py b/construct/ui/widgets.py deleted file mode 100644 index 1bc9eee..0000000 --- a/construct/ui/widgets.py +++ /dev/null @@ -1,132 +0,0 @@ -# -*- coding: utf-8 -*- - -from __future__ import absolute_import - -# Third party imports -from Qt import QtCore, QtWidgets - -# Local imports -from .scale import pt -from .theme import theme - - -__all__ = [ - 'H1', - 'H2', - 'H3', - 'H4', - 'H5', - 'Header', - 'P', -] - - -class BaseLabel(QtWidgets.QLabel): - - css_id = '' - - def __init__(self, *args, **kwargs): - super(BaseLabel, self).__init__(*args, **kwargs) - self.setObjectName(self.css_id) - - -class H1(BaseLabel): - - css_id = 'h1' - - -class H2(BaseLabel): - - css_id = 'h2' - - -class H3(BaseLabel): - - css_id = 'h3' - - -class H4(BaseLabel): - - css_id = 'h2' - - -class H5(BaseLabel): - - css_id = 'h3' - - -class P(BaseLabel): - - css_id = 'p' - - def __init__(self, *args, **kwargs): - super(P, self).__init__(*args, **kwargs) - self.setWordWrap(True) - - -class Button(QtWidgets.QPushButton): - - css_id = 'text-button' - - def __init__(self, text, icon=None, icon_size=None, **kwargs): - super(Button, self).__init__(**kwargs) - self.setObjectName(self.css_id) - self.setText(text) - if icon: - self.setIcon(theme.icon(icon)) - if icon_size: - self.setIconSize(QtCore.QSize(pt(icon_size[0]), pt(icon_size[1]))) - - -class ToolButton(QtWidgets.QPushButton): - - css_id = 'tool-button' - - def __init__(self, text, icon=None, icon_size=None, **kwargs): - super(ToolButton, self).__init__(**kwargs) - self.setObjectName(self.css_id) - - self.label = P(text, parent=self) - self.label.setWordWrap(True) - self.label.setAlignment(QtCore.Qt.AlignCenter) - self.label.setMouseTracking(False) - self.label.setTextInteractionFlags(QtCore.Qt.NoTextInteraction) - self.label.setSizePolicy( - QtWidgets.QSizePolicy.Expanding, - QtWidgets.QSizePolicy.Expanding, - ) - - self.glyph = Glyph(icon, icon_size, parent=self) - - self.layout = QtWidgets.QVBoxLayout(self) - self.layout.setContentsMargins(pt(8), pt(8), pt(8), pt(8)) - self.layout.setSpacing(pt(4)) - self.layout.addWidget(self.glyph) - self.layout.addWidget(self.label) - self.setLayout(self.layout) - - def sizeHint(self): - return QtCore.QSize( - self.glyph.size.width() + pt(16), - self.glyph.size.height() + pt(32), - ) - - -class Glyph(QtWidgets.QLabel): - - css_id = 'icon' - - def __init__(self, icon, icon_size, parent=None): - super(Glyph, self).__init__(parent=parent) - - self.icon = theme.icon(icon, parent=parent) - if icon_size: - self.size = QtCore.QSize(pt(icon_size[0]), pt(icon_size[1])) - else: - self.size = QtCore.QSize(pt(24), pt(24)) - self.setPixmap(self.icon.pixmap(self.size)) - self.setFixedSize(self.size) - self.setSizePolicy( - QtWidgets.QSizePolicy.Fixed, - QtWidgets.QSizePolicy.Fixed, - ) diff --git a/construct/ui/widgets/__init__.py b/construct/ui/widgets/__init__.py new file mode 100644 index 0000000..1e588f8 --- /dev/null +++ b/construct/ui/widgets/__init__.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +''' +isort:skip_file +''' + +# Local imports +from .widget import * +from .frameless import * +from .lines import * +from .icons import * +from .labels import * +from .buttons import * +from .header import * +from .navigation import * +from .sidebar import * +from .splitters import * diff --git a/construct/ui/widgets/buttons.py b/construct/ui/widgets/buttons.py new file mode 100644 index 0000000..22c9685 --- /dev/null +++ b/construct/ui/widgets/buttons.py @@ -0,0 +1,128 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import + +# Third party imports +from Qt import QtCore, QtWidgets + +# Local imports +from ..scale import pt, px +from ..theme import theme +from . import P, Widget + + +__all__ = [ + 'Button', + 'Glyph', + 'IconButton', + 'ToolButton', +] + + +class Button(Widget, QtWidgets.QPushButton): + + css_id = '' + css_properties = { + 'type': 'text', + } + + def __init__(self, text=None, icon=None, icon_size=None, **kwargs): + super(Button, self).__init__(**kwargs) + self.setFlat(True) + + if text: + self.setText(text) + + if icon: + self.setIcon(theme.icon(icon)) + + if icon_size: + self.setIconSize(QtCore.QSize(*px(icon_size[0], icon_size[1]))) + else: + self.size = QtCore.QSize(*px(24, 24)) + + +class IconButton(Widget, QtWidgets.QPushButton): + + css_id = '' + css_properties = { + 'type': 'icon', + } + + def __init__(self, icon, icon_size=None, on_icon=None, **kwargs): + super(IconButton, self).__init__(**kwargs) + self.setFlat(True) + self.set_icon(icon, icon_size) + + self._icon = icon + self._icon_size = icon_size + self._on_icon = on_icon + + if self._on_icon: + self.toggled.connect(self.update_icon) + + def update_icon(self, state): + icon = self._icon + if state and self._on_icon: + icon = self._on_icon + self.set_icon(icon, self._icon_size) + + def set_icon(self, icon, icon_size=None, on_icon=None): + self.setIcon(theme.icon(icon, parent=self)) + if icon_size: + self.size = QtCore.QSize(*px(icon_size[0], icon_size[1])) + else: + self.size = QtCore.QSize(*px(24, 24)) + + self.setIconSize(self.size) + self.setFixedSize(self.size) + self.setSizePolicy( + QtWidgets.QSizePolicy.Fixed, + QtWidgets.QSizePolicy.Fixed, + ) + + +class ToolButton(Widget, QtWidgets.QPushButton): + + css_id = '' + css_properties = { + 'type': 'tool', + } + + def __init__(self, text, icon=None, icon_size=None, **kwargs): + super(ToolButton, self).__init__(**kwargs) + + self.label = P(text, parent=self) + self.label.setWordWrap(True) + self.label.setAlignment(QtCore.Qt.AlignCenter) + self.label.setMouseTracking(False) + self.label.setTextInteractionFlags(QtCore.Qt.NoTextInteraction) + self.label.setSizePolicy( + QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Expanding, + ) + + self.glyph = Glyph(icon, icon_size, parent=self) + + self.layout = QtWidgets.QVBoxLayout(self) + self.layout.setContentsMargins(*px(8, 8, 8, 8)) + self.layout.setSpacing(px(4)) + self.layout.addWidget(self.glyph) + self.layout.addWidget(self.label) + self.setLayout(self.layout) + + def sizeHint(self): + return QtCore.QSize( + self.glyph.size.width() + px(16), + self.glyph.size.height() + px(32), + ) + + +class Glyph(IconButton): + + css_id = 'icon' + css_properties = {} + + def __init__(self, icon, icon_size=None, parent=None): + super(Glyph, self).__init__(icon, icon_size, parent=parent) + self.setDisabled(True) diff --git a/construct/ui/widgets/frameless.py b/construct/ui/widgets/frameless.py new file mode 100644 index 0000000..051a6f7 --- /dev/null +++ b/construct/ui/widgets/frameless.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import + +# Third party imports +from Qt import QtCore, QtWidgets + +# Local imports +from ..scale import px +from ..theme import theme +from . import Widget + + +__all__ = [ + 'Frameless', +] +missing = object() + + +class Frameless(Widget): + '''Mixin class for frameless resizeable widgets. + + Classes that have Widgets as a base class should set css_id and any + css_properties used in themeing. + + Example: + + class MyDialog(Frameless, QtWidgets.QDialog): + + css_id = 'my_label' + css_properties = { + 'error': False, + } + ''' + + _resize_area_map = { + (False, False, False, False): None, + (True, False, False, False): 'left', + (True, True, False, False): 'topLeft', + (False, True, False, False): 'top', + (False, True, True, False): 'topRight', + (False, False, True, False): 'right', + (False, False, True, True): 'bottomRight', + (False, False, False, True): 'bottom', + (True, False, False, True): 'bottomLeft' + } + _cursor_map = { + None: QtCore.Qt.ArrowCursor, + 'left': QtCore.Qt.SizeHorCursor, + 'topLeft': QtCore.Qt.SizeFDiagCursor, + 'top': QtCore.Qt.SizeVerCursor, + 'topRight': QtCore.Qt.SizeBDiagCursor, + 'right': QtCore.Qt.SizeHorCursor, + 'bottomRight': QtCore.Qt.SizeFDiagCursor, + 'bottom': QtCore.Qt.SizeVerCursor, + 'bottomLeft': QtCore.Qt.SizeBDiagCursor + } + + def __init__(self, *args, **kwargs): + super(Frameless, self).__init__(*args, **kwargs) + + self._mouse_pressed = False + self._mouse_position = None + self._resize_area = None + self.resize_area_size = px(5) + self.setMouseTracking(True) + self.setWindowFlags( + QtCore.Qt.Window | + QtCore.Qt.WindowStaysOnTopHint | + QtCore.Qt.FramelessWindowHint + ) + self.setWindowTitle('construct') + self.setWindowIcon(theme.icon('brand/construct_icon-white.png')) + self.setAttribute(QtCore.Qt.WA_Hover) + self.installEventFilter(self) + + theme.apply(self) + + @property + def resizing(self): + return bool(self._resize_area) + + def _check_resize_area(self, pos): + x, y = pos.x(), pos.y() + return self._resize_area_map[( + x < self.resize_area_size, + y < self.resize_area_size, + x > self.width() - self.resize_area_size, + y > self.height() - self.resize_area_size, + )] + + def _update_resize_area(self, pos): + self._resize_area = self._check_resize_area(pos) + + def _update_cursor(self, cursor=missing): + if cursor is not missing: + self.setCursor(self._cursor_map[cursor]) + else: + self.setCursor(self._cursor_map.get(self._resize_area, None)) + + def eventFilter(self, obj, event): + if event.type() == QtCore.QEvent.HoverMove: + self._update_cursor(self._check_resize_area(event.pos())) + return True + + if event.type() == QtCore.QEvent.Leave: + self.setCursor(QtCore.Qt.ArrowCursor) + return True + + return False + + def mousePressEvent(self, event): + if event.buttons() & QtCore.Qt.LeftButton: + pos = event.pos() + self._update_resize_area(pos) + self._mouse_pressed = True + self._mouse_position = pos + + def mouseMoveEvent(self, event): + if not self._mouse_pressed: + pos = event.pos() + self._update_resize_area(pos) + + if self._mouse_pressed: + vector = event.pos() - self._mouse_position + offset = event.globalPos() + + if self.resizing: + min_width = self.minimumWidth() + min_height = self.minimumHeight() + rect = self.geometry() + resize_area = self._resize_area.lower() + + if 'left' in resize_area: + new_width = rect.width() - vector.x() + if new_width > min_width: + rect.setLeft(offset.x()) + + if 'right' in resize_area: + new_width = rect.width() + vector.x() + if new_width > min_width: + rect.setRight(offset.x()) + + if 'top' in resize_area: + new_height = rect.height() - vector.y() + if new_height > min_height: + rect.setTop(offset.y()) + + if 'bottom' in resize_area: + new_height = rect.height() + vector.y() + if new_height > min_height: + rect.setBottom(offset.y()) + + self.setGeometry(rect) + + else: + self.move(self.mapToParent(vector)) + + def mouseReleaseEvent(self, event): + self._mouse_pressed = False + self._mouse_position = None diff --git a/construct/ui/widgets/header.py b/construct/ui/widgets/header.py new file mode 100644 index 0000000..6be0716 --- /dev/null +++ b/construct/ui/widgets/header.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- + +# Third party imports +from Qt import QtCore, QtWidgets + +# Local imports +from ..layouts import HBarLayout +from ..scale import px +from . import H2, Glyph, IconButton, Widget + + +__all__ = [ + 'Header', +] + + +class Header(Widget, QtWidgets.QWidget): + + css_id = 'background' + css_properties = { + 'theme': 'background', + } + + def __init__(self, label, *args, **kwargs): + super(Header, self).__init__(*args, **kwargs) + + self.setFixedHeight(px(36)) + + self.glyph = Glyph( + 'construct', + icon_size=(24, 24), + parent=self, + ) + self.title = H2( + label, + parent=self, + ) + self.close_button = IconButton( + icon='close', + icon_size=(24, 24), + parent=self, + ) + self.close_button.setFocusPolicy(QtCore.Qt.NoFocus) + + self.layout = HBarLayout() + self.layout.setContentsMargins(*px(16, 0, 16, 0)) + self.layout.left.addWidget(self.glyph) + self.layout.center.addWidget(self.title, stretch=1) + self.layout.right.addWidget(self.close_button) + self.setLayout(self.layout) diff --git a/construct/ui/icons.py b/construct/ui/widgets/icons.py similarity index 91% rename from construct/ui/icons.py rename to construct/ui/widgets/icons.py index f8c6729..8a756bb 100644 --- a/construct/ui/icons.py +++ b/construct/ui/widgets/icons.py @@ -6,6 +6,14 @@ from Qt.QtSvg import QSvgRenderer +__all__ = [ + 'SvgIconEngine', + 'SvgIcon', + 'FontIconEngine', + 'FontIcon', +] + + class SvgIconEngine(QIconEngine): '''Handles painting of SVG icons.''' @@ -67,7 +75,9 @@ def paint(self, painter, rect, alignment, mode, state): painter.setFont(font) if self.parent: # Set color from parent - painter.setPen(self.parent.palette().text().color()) + palette = self.parent.palette() + color = palette.color(palette.Normal, palette.Text) + painter.setPen(color) painter.drawText(rect, alignment, self.char) diff --git a/construct/ui/widgets/labels.py b/construct/ui/widgets/labels.py new file mode 100644 index 0000000..8b65ee7 --- /dev/null +++ b/construct/ui/widgets/labels.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import + +# Third party imports +from Qt import QtWidgets + +# Local imports +from . import Widget + + +__all__ = [ + 'BaseLabel', + 'H1', + 'H2', + 'H3', + 'H4', + 'H5', + 'P', +] + + +class BaseLabel(Widget, QtWidgets.QLabel): + + def __init__(self, *args, **kwargs): + super(BaseLabel, self).__init__(*args, **kwargs) + + +class H1(BaseLabel): + + css_id = 'h1' + + +class H2(BaseLabel): + + css_id = 'h2' + + +class H3(BaseLabel): + + css_id = 'h3' + + +class H4(BaseLabel): + + css_id = 'h4' + + +class H5(BaseLabel): + + css_id = 'h5' + + +class P(BaseLabel): + + css_id = 'p' + + def __init__(self, *args, **kwargs): + super(P, self).__init__(*args, **kwargs) + self.setWordWrap(True) diff --git a/construct/ui/widgets/lines.py b/construct/ui/widgets/lines.py new file mode 100644 index 0000000..a08b3cc --- /dev/null +++ b/construct/ui/widgets/lines.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- + +# Third party imports +from Qt import QtWidgets + +# Local imports +from ..scale import px +from . import Widget + + +__all__ = [ + 'HLine', + 'VLine', +] + + +class HLine(Widget, QtWidgets.QFrame): + + css_id = 'line' + + def __init__(self, *args, **kwargs): + super(HLine, self).__init__(*args, **kwargs) + + self.setFrameShape(QtWidgets.QFrame.HLine) + self.setSizePolicy( + QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Fixed, + ) + self.setFixedHeight(px(1)) + + +class VLine(Widget, QtWidgets.QFrame): + + css_id = 'line' + + def __init__(self, *args, **kwargs): + super(HLine, self).__init__(*args, **kwargs) + + self.setFrameShape(QtWidgets.QFrame.VLine) + self.setSizePolicy( + QtWidgets.QSizePolicy.Fixed, + QtWidgets.QSizePolicy.Expanding, + ) + self.setFixedWidth(px(1)) diff --git a/construct/ui/widgets/navigation.py b/construct/ui/widgets/navigation.py new file mode 100644 index 0000000..6ed7fa4 --- /dev/null +++ b/construct/ui/widgets/navigation.py @@ -0,0 +1,250 @@ +# -*- coding: utf-8 -*- + +# Third party imports +from Qt import QtCore, QtGui, QtWidgets + +# Local imports +from ..layouts import HBarLayout +from ..scale import px +from . import H2, Button, Glyph, IconButton, P, Widget + + +__all__ = [ + 'Navigation', +] + + +class Navigation(Widget, QtWidgets.QWidget): + + css_id = 'navigation' + css_properties = { + 'theme': 'surface', + } + uri_changed = QtCore.Signal(str) + + def __init__(self, *args, **kwargs): + super(Navigation, self).__init__(*args, **kwargs) + + self.setFixedHeight(px(36)) + + self.menu_button = IconButton( + icon='menu', + icon_size=(24, 24), + parent=self, + ) + self.home_button = IconButton( + icon='home', + icon_size=(24, 24), + parent=self, + ) + + self.crumbs = Crumbs(parent=self) + self.crumbs_editor = CrumbsEditor(parent=self) + self.crumbs_editor.hide() + self.crumbs_editor.returnPressed.connect(self.commit_edit_crumbs) + self.crumbs_editor.focus_lost.connect(self.done_edit_crumbs) + + self.bookmark_button = IconButton( + icon='bookmark_outline', + icon_size=(24, 24), + parent=self, + ) + self.bookmark_button.setCheckable(True) + + self.layout = HBarLayout(parent=self) + self.layout.setSpacing(0) + self.layout.setContentsMargins(*px(16, 6, 16, 6)) + self.layout.left.addWidget(self.menu_button) + self.layout.left.addWidget(self.home_button) + self.layout.center.addWidget(self.crumbs) + self.layout.center.addWidget(self.crumbs_editor) + self.layout.center.setAlignment(QtCore.Qt.AlignLeft) + self.layout.right.addWidget(self.bookmark_button) + self.setLayout(self.layout) + + self.setAttribute(QtCore.Qt.WA_Hover) + self.installEventFilter(self) + + def _update_focus_order(self): + prev_widget = None + for lo in (self.layout.left, self.layout.center, self.layout.right): + for i in range(lo.count()): + next_widget = lo.itemAt(i).widget() + + # We hit some crumbs baby + if isinstance(next_widget, Crumbs): + for crumb in next_widget.iter(): + if prev_widget: + self.setTabOrder(prev_widget, crumb.label) + self.setTabOrder(crumb.label, crumb.arrow) + prev_widget = crumb.arrow + + # It's a normal widget - probably an IconButton + if next_widget: + if prev_widget: + self.setTabOrder(prev_widget, next_widget) + prev_widget = next_widget + + def edit_crumbs(self): + self.crumbs.hide() + self.crumbs_editor.show() + uri = 'cons://' + '/'.join([ + c.label.text() + for c in self.crumbs.iter() + if c.label.text() and c.label.text() != 'home' + ]) + self.crumbs_editor.setText(uri) + self.crumbs_editor.setFocus() + + def done_edit_crumbs(self): + self.crumbs.show() + self.crumbs_editor.hide() + self.parent().setFocus() + + def commit_edit_crumbs(self): + self.done_edit_crumbs() + uri = self.crumbs_editor.text() + self.uri_changed.emit(uri) + + def eventFilter(self, obj, event): + '''Sets appropriate cursor when hovering over Navigation.''' + + if event.type() == QtCore.QEvent.HoverMove: + child = self.childAt(event.pos()) + if child and isinstance(child, Crumbs): + self.setCursor(QtCore.Qt.IBeamCursor) + else: + self.setCursor(QtCore.Qt.ArrowCursor) + return True + + if event.type() == QtCore.QEvent.Leave: + self.setCursor(QtCore.Qt.ArrowCursor) + return True + + return super(Navigation, self).eventFilter(obj, event) + + def mousePressEvent(self, event): + if event.buttons() & QtCore.Qt.LeftButton: + child = self.childAt(event.pos()) + if child and isinstance(child, Crumbs): + self.edit_crumbs() + + +class CrumbsEditor(Widget, QtWidgets.QLineEdit): + + focus_lost = QtCore.Signal() + + css_id = 'crumbs' + css_properties = {} + + def __init__(self, *args, **kwargs): + super(CrumbsEditor, self).__init__(*args, **kwargs) + + self.setSizePolicy( + QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Minimum, + ) + + def keyPressEvent(self, event): + '''Sets appropriate cursor when hovering over Navigation.''' + + if event.key() == QtCore.Qt.Key_Escape: + self.hide() + return True + + return super(CrumbsEditor, self).keyPressEvent(event) + + def focusOutEvent(self, event): + self.focus_lost.emit() + + +class Crumbs(Widget, QtWidgets.QWidget): + + css_id = 'crumbs' + css_properties = {} + + def __init__(self, *args, **kwargs): + super(Crumbs, self).__init__(*args, **kwargs) + + self.setSizePolicy( + QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Expanding, + ) + + self.layout = QtWidgets.QHBoxLayout() + self.layout.setContentsMargins(0, 0, 0, 0) + self.layout.setAlignment(QtCore.Qt.AlignLeft) + self.layout.setSpacing(0) + self.setLayout(self.layout) + + def iter(self): + for item in range(self.layout.count()): + yield self.layout.itemAt(item).widget() + + def clear(self): + while self.layout.count(): + child = self.layout.takeAt(0) + if child.widget(): + child.widget().deleteLater() + + def add(self, label): + crumb = Crumb(label, parent=self) + self.layout.addWidget(crumb) + return crumb + + +class Crumb(Widget, QtWidgets.QWidget): + + css_id = 'crumb' + + def __init__(self, label, *args, **kwargs): + super(Crumb, self).__init__(*args, **kwargs) + + self.label = QtWidgets.QPushButton(label, parent=self) + self.label.setSizePolicy( + QtWidgets.QSizePolicy.Minimum, + QtWidgets.QSizePolicy.Expanding, + ) + self.setProperty('position', 'left') + + self.menu = QtWidgets.QMenu(parent=self) + self.menu.setWindowFlags( + self.menu.windowFlags() | QtCore.Qt.NoDropShadowWindowHint + ) + + self.arrow = QtWidgets.QPushButton(parent=self) + self.arrow.setFlat(True) + self.arrow.setFixedWidth(px(16)) + self.arrow.setMenu(self.menu) + self.arrow.setSizePolicy( + QtWidgets.QSizePolicy.Minimum, + QtWidgets.QSizePolicy.Expanding, + ) + self.setProperty('position', 'right') + + self.setAttribute(QtCore.Qt.WA_Hover) + self.setSizePolicy( + QtWidgets.QSizePolicy.Minimum, + QtWidgets.QSizePolicy.Expanding, + ) + + self.layout = QtWidgets.QHBoxLayout() + self.layout.addWidget(self.label) + self.layout.addWidget(self.arrow) + self.layout.setSpacing(0) + self.layout.setContentsMargins(0, 0, 0, 0) + self.setLayout(self.layout) + + self.label.installEventFilter(self) + self.arrow.installEventFilter(self) + + def eventFilter(self, obj, event): + '''Sets appropriate cursor when hovering over Navigation.''' + + if event.type() == QtCore.QEvent.KeyPress: + if event.key() == QtCore.Qt.Key_Down: + self.arrow.setFocus() + self.arrow.click() + return True + + return super(Crumb, self).eventFilter(obj, event) diff --git a/construct/ui/widgets/sidebar.py b/construct/ui/widgets/sidebar.py new file mode 100644 index 0000000..b924309 --- /dev/null +++ b/construct/ui/widgets/sidebar.py @@ -0,0 +1,401 @@ +# -*- coding: utf-8 -*- + +# Third party imports +from Qt import QtCore, QtGui, QtWidgets + +# Local imports +from .. import models +from ..scale import px +from . import Button, HLine, IconButton, Widget + + +__all__ = [ + 'Sidebar', +] + + +class SidebarTree(Widget, QtWidgets.QTreeView): + + css_id = 'SidebarTree' + css_properties = {} + selection_changed = QtCore.Signal(object, object) + + def __init__(self, *args, **kwargs): + super(SidebarTree, self).__init__(*args, **kwargs) + self.setHeaderHidden(True) + self.expandAll() + self.setSizePolicy( + QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Expanding, + ) + + def select_by_name(self, *items): + sel = self.selectionModel() + model = self.model() + + selection = QtCore.QItemSelection() + for item in items: + index = model.match( + model.index(0, 0), + QtCore.Qt.DisplayRole, + item, + 1, + QtCore.Qt.MatchRecursive + ) + if index: + selection.merge( + QtCore.QItemSelection(index[0], index[0]), + sel.Select, + ) + + sel.blockSignals(True) + sel.clear() + sel.select(selection, sel.Select) + sel.blockSignals(False) + self.update() + + def get_selected_data(self): + items = [] + for index in self.selectionModel().selectedIndexes(): + items.append(self.get_data(index)) + return items + + def get_data(self, index): + proxy = self.model() + model = proxy.sourceModel() + index = proxy.mapToSource(index) + return model.getNode(index).value() + + def get_parent(self, index): + proxy = self.model() + model = proxy.sourceModel() + index = proxy.mapToSource(index) + return self.get_data(model.parent(index)) + + def selectionChanged(self, next, prev): + self.selection_changed.emit(self.get_selected_data(), None) + super(SidebarTree, self).selectionChanged(next, prev) + + +class SidebarTools(Widget, QtWidgets.QWidget): + + css_id = 'SidebarTools' + css_properties = {} + + def __init__(self, *args, **kwargs): + super(SidebarTools, self).__init__(*args, **kwargs) + + self.filter_button = IconButton( + icon='filter', + icon_size=(24, 24), + parent=self, + ) + self.filter = QtWidgets.QLineEdit(parent=self) + self.filter.setPlaceholderText('Filter') + self.menu_button = IconButton( + icon='menu_dots', + icon_size=(24, 24), + parent=self, + ) + + self.layout = QtWidgets.QHBoxLayout() + self.layout.setStretch(1, 1) + self.layout.setContentsMargins(*px(16, 8, 8, 8)) + self.layout.addWidget(self.filter_button) + self.layout.addWidget(self.filter) + self.layout.addWidget(self.menu_button) + self.setLayout(self.layout) + + +class SidebarTabs(Widget, QtWidgets.QWidget): + + css_id = 'SidebarTabs' + css_properties = { + 'theme': 'surface', + } + changed = QtCore.Signal(object) + + def __init__(self, *args, **kwargs): + super(SidebarTabs, self).__init__(*args, **kwargs) + + self.tabs = [] + self.tabs_group = QtWidgets.QButtonGroup(parent=self) + self.tabs_group.setExclusive(True) + self.tabs_group.buttonClicked.connect(self.changed.emit) + + self.add_button = IconButton( + icon='plus', + icon_size=(14, 14), + parent=self, + ) + + self.left = QtWidgets.QHBoxLayout() + self.left.setContentsMargins(0, 0, 0, 0) + self.left.setSpacing(0) + + self.right = QtWidgets.QHBoxLayout() + self.right.addWidget(self.add_button) + self.right.setContentsMargins(*px(8, 8, 8, 8)) + + self.layout = QtWidgets.QHBoxLayout() + self.layout.addLayout(self.left) + self.layout.addLayout(self.right) + self.layout.addStretch(1) + self.layout.setSpacing(0) + self.layout.setContentsMargins(0, 0, 0, 0) + + self.setLayout(self.layout) + self.setFixedHeight(px(36)) + self.setMinimumWidth(px(256)) + + def get(self): + return self.tabs_group.checkedButton().text() + + def set(self, label): + for tab in list(self.tabs): + if tab.text() == label: + tab.setChecked(True) + + def add(self, label, icon=None): + tab = Button(label, icon=icon, parent=self) + tab.setObjectName('tab') + tab.setSizePolicy( + QtWidgets.QSizePolicy.Minimum, + QtWidgets.QSizePolicy.Maximum + ) + tab.setFixedHeight(px(36)) + tab.setCheckable(True) + tab.setChecked(True) + self.tabs.append(tab) + self.left.addWidget(tab) + self.tabs_group.addButton(tab) + return tab + + def remove(self, label): + for tab in list(self.tabs): + if tab.text() == label: + self.tabs_group.removeButton(tab) + self.tabs.remove(tab) + tab.setParent(None) + tab.deleteLater() + + def clear(self): + while self.tabs: + tab = self.tabs.pop() + self.tabs_group.removeButton(tab) + tab.setParent(None) + tab.deleteLater() + + +class SidebarBase(Widget, QtWidgets.QWidget): + + css_id = 'Sidebar' + css_properties = { + 'theme': 'surface', + } + + def __init__(self, *args, **kwargs): + super(SidebarBase, self).__init__(*args, **kwargs) + + self.tabs = SidebarTabs(parent=self) + self.tools = SidebarTools(parent=self) + self.tree = SidebarTree(parent=self) + + self.layout = QtWidgets.QVBoxLayout() + self.layout.setSpacing(0) + self.layout.setContentsMargins(0, 0, 0, 0) + self.layout.setStretch(3, 1) + self.layout.addWidget(self.tabs) + self.layout.addWidget(self.tools) + self.layout.addWidget(HLine(self)) + self.layout.addWidget(self.tree) + self.setLayout(self.layout) + + self.setMinimumWidth(px(200)) + self.setSizePolicy( + QtWidgets.QSizePolicy.Minimum, + QtWidgets.QSizePolicy.Expanding, + ) + + +class Sidebar(SidebarBase): + + def __init__(self, state, *args, **kwargs): + super(Sidebar, self).__init__(*args, **kwargs) + self.state = state + self._selection_disabled = False + + self.tabs.changed.connect(self._on_tab_changed) + self.tree.doubleClicked.connect(self._on_tree_doubleClicked) + self.tree.selection_changed.connect(self._on_tree_selection) + self.tools.filter.textChanged.connect(self._on_filter_text_changed) + + self.state['context'].changed.connect(self._refresh) + self.state['tree_model'].changed.connect(self._on_tree_model_changed) + + self._suppress_refresh = False + self._refresh(self.state['context'].copy()) + + def _on_filter_text_changed(self, text): + model = self.state['tree_proxy_model'].get() + model.setFilterRegExp(text) + self.tree.expandAll() + + def _tree_requires_refresh(self, tree_context, context): + for key in ['location', 'mount', 'project', 'bin']: + if context.get(key, None) != tree_context.get(key, None): + return True + return False + + def _selection_requires_refresh(self, tree_context, context): + for key in ['asset', 'project', 'mount']: + if context.get(key, None) != tree_context.get(key, None): + return True + return False + + def _refresh(self, context): + + api = self.state['api'].get() + context = context.copy() + proxy = self.tree.model() + if not proxy: + tree_context = {} + else: + tree_context = proxy.sourceModel().context() + + self.tree.blockSignals(True) + + if self._tree_requires_refresh(tree_context, context): + with api.set_context(context): + if context['project']: + self.tree.setSelectionMode( + self.tree.ExtendedSelection + ) + project = api.io.get_project( + context['project'], + context['location'], + context['mount'], + ) + bin = context['bin'] or next(iter(project['bins'])) + context['bin'] = bin + + model = models.AssetsTreeModel(api, context, project, bin) + self._refresh_project_tabs(context, project, bin) + elif context['location']: + self.tree.setSelectionMode( + self.tree.SingleSelection + ) + + mount = context['mount'] + locations = api.get_locations() + mount = mount or next(iter(locations[context['location']])) + context['mount'] = mount + + model = models.ProjectsTreeModel(api, context, mount) + self._refresh_location_tabs(context, mount) + else: + self.tree.setSelectionMode( + self.tree.SingleSelection + ) + self.tabs.hide() + model = models.LocationsTreeModel(api, context) + + self.state.set('tree_model', model) + + if self._selection_requires_refresh(tree_context, context): + for item in ['asset', 'project', 'mount']: + if context[item]: + self.tree.select_by_name(context[item]) + break + + self.tree.blockSignals(False) + + def _refresh_project_tabs(self, context, project, bin): + bins = sorted(project['bins'].items(), key=lambda b: b[1]['order']) + self.tabs.clear() + self.tabs.show() + self.tabs.add_button.show() + for bin_name, bin_data in bins: + self.tabs.add(bin_name) + + self.tabs.set(bin) + + def _refresh_location_tabs(self, context, mount): + api = self.state['api'].get() + + self.tabs.clear() + self.tabs.show() + self.tabs.add_button.hide() + + locations = api.get_locations() + mounts = list(locations[context['location']]) + for mount_name in mounts: + self.tabs.add(mount_name) + + self.tabs.set(mount) + + def _on_tab_changed(self, *args): + context = self.state['context'].copy() + tab_value = self.tabs.get() + tab_key = None + + if context['project']: + tab_key = 'bin' + elif context['location']: + tab_key = 'mount' + else: + return + + context = context.trim(tab_key) + context[tab_key] = tab_value + self.state.set('context', context) + + def _on_tree_doubleClicked(self, index): + data = self.tree.get_data(index) + + if data['_type'] in ['location']: + return + + new_context = {data['_type']: data['name']} + if data['_type'] == 'mount': + new_context['location'] = data['location'] + + if data['_type'] in self.state['context']: + context = self.state['context'].copy().trim(data['_type']) + context.update(new_context) + self.state.set('context', context) + + def _on_tree_selection(self, selection, prev_selection): + self._suppress_refresh = True + context = self.state['context'].copy() + if selection: + first = selection[0] + if ( + first['_type'] == 'asset' and + context['asset'] != first['name'] + ): + context = context.trim('asset') + context.update(bin=first['bin'], asset=first['name']) + self.state.set('context', context) + return + + if context['asset'] or context['bin']: + context = context.trim('bin') + self.state.set('context', context) + elif context['project']: + context = context.trim('project') + self.state.set('context', context) + + self.state.set('selection', selection) + self._suppress_refresh = False + + def _on_tree_model_changed(self, model): + self.tree.blockSignals(True) + proxy_model = QtCore.QSortFilterProxyModel(self.tree) + proxy_model.setSourceModel(model) + proxy_model.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) + self.state.set('tree_proxy_model', proxy_model) + self.tree.setModel(proxy_model) + self.tree.expandAll() + self.tools.filter.setText('') + self.tree.blockSignals(False) diff --git a/construct/ui/widgets/widget.py b/construct/ui/widgets/widget.py new file mode 100644 index 0000000..e60bc3f --- /dev/null +++ b/construct/ui/widgets/widget.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- + +# Third party imports +from Qt import QtCore + + +__all__ = [ + 'Widget', +] + + +class Widget(object): + '''Mixin class for all themable widgets. + + Classes that have Widgets as a base class should set css_id and any + css_properties used in themeing. + + Example: + + class MyWidget(Widget, QtWidgets.QLabel): + + css_id = 'my_label' + css_properties = { + 'error': False, + } + ''' + + css_id = '' + css_properties = {} + + def __init__(self, *args, **kwargs): + super(Widget, self).__init__(*args, **kwargs) + + for prop, value in self.css_properties.items(): + self.setProperty(prop, value) + + if self.css_id: + self.setObjectName(self.css_id) + + self.setAttribute(QtCore.Qt.WA_StyledBackground) diff --git a/construct/utils.py b/construct/utils.py index a5e0805..1a4f2b8 100644 --- a/construct/utils.py +++ b/construct/utils.py @@ -43,6 +43,7 @@ def yaml_dump(data, **kwargs): kwargs.setdefault('allow_unicode', True) kwargs.setdefault('encoding', 'utf-8') kwargs.setdefault('default_flow_style', False) + kwargs.setdefault('sort_keys', False) return yaml.safe_dump(data, **kwargs) diff --git a/requirements-dev.txt b/requirements-dev.txt index 4f78120..933a889 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,6 +2,7 @@ invoke isort mock; python_version<"3" nose +nose-exclude PySide2; python_version>"3.4" sphinx sphinx_rtd_theme diff --git a/tasks.py b/tasks.py index fdaceef..5fb7ae1 100644 --- a/tasks.py +++ b/tasks.py @@ -11,6 +11,9 @@ from invoke import Collection, Program, task +PY2 = sys.version_info.major < 3 + + def joinpath(*parts): return os.path.join(*parts).replace('\\', '/') @@ -25,6 +28,11 @@ def tests(ctx, level='WARNING', module=None): '--nocapture ' '--logging-level=%s ' % level ) + + if PY2: + # Prevent nose from importing UI modules + nose_cmd += '--exclude-dir="construct/ui"' + if module: nose_cmd += module diff --git a/tests/__init__.py b/tests/__init__.py index 4514f5b..e7b0a65 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -12,7 +12,6 @@ # Local imports import construct -from construct.constants import DEFAULT_LOGGING from construct.settings import restore_default_settings from construct.utils import unipath @@ -34,7 +33,8 @@ 'level': 'WARNING', 'handlers': ['console'], } -}) + }, +) @nottest diff --git a/tests/test_context.py b/tests/test_context.py index c6f9fa8..a5accc5 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -14,19 +14,8 @@ def test_default_context(): ctx = Context() - assert ctx.user is not None - assert ctx.host is not None - assert ctx.platform is not None - - -def test_context_attr_access(): - '''Context attr access''' - - ctx = Context() - ctx.x = 1 - - assert 'x' in ctx - assert ctx['x'] == 1 + # These values are not None + assert ctx['user'] and ctx['host'] and ctx['platform'] def test_copy_context(): @@ -44,9 +33,9 @@ def test_store_and_load_context(): '''Store a context and then load it''' ctx = Context() - ctx.project = 'project' - ctx.bin = 'bin' - ctx.asset = 'asset' + ctx['project'] = 'project' + ctx['bin'] = 'bin' + ctx['asset'] = 'asset' ctx.store() assert 'CONSTRUCT_PROJECT' in os.environ @@ -56,9 +45,9 @@ def test_store_and_load_context(): new_ctx = Context() new_ctx.load() - assert new_ctx.project == 'project' - assert new_ctx.bin == 'bin' - assert new_ctx.asset == 'asset' + assert new_ctx['project'] == 'project' + assert new_ctx['bin'] == 'bin' + assert new_ctx['asset'] == 'asset' # Make sure we don't leak context to other tests new_ctx.clear_env() diff --git a/tests/test_migration.py b/tests/test_migration.py index 148a6f0..7bfed66 100644 --- a/tests/test_migration.py +++ b/tests/test_migration.py @@ -2,17 +2,9 @@ from __future__ import absolute_import -# Standard library imports -import unittest - -# Third party imports -import fsfs - # Local imports import construct from construct import migrations -from construct.settings import restore_default_settings -from construct.utils import unipath # Local imports from . import data_dir, setup_api, teardown_api @@ -21,55 +13,6 @@ MIGRATIONS_DIR = data_dir('migrations') -def _setup_old_project(where): - - def new_asset(p, col, typ, asset): - return [ - (p/'3D'/col, 'collection'), - (p/'3D'/col/typ, 'asset_type'), - (p/'3D'/col/typ/asset, 'asset'), - (p/'3D'/col/typ/asset/'model', 'task'), - (p/'3D'/col/typ/asset/'model/work/maya', 'workspace'), - (p/'3D'/col/typ/asset/'rig', 'task'), - (p/'3D'/col/typ/asset/'rig/work/maya', 'workspace'), - (p/'3D'/col/typ/asset/'shade', 'task'), - (p/'3D'/col/typ/asset/'shade/work/maya', 'workspace'), - (p/'3D'/col/typ/asset/'light', 'task'), - (p/'3D'/col/typ/asset/'light/work/maya', 'workspace'), - (p/'3D'/col/typ/asset/'comp', 'task'), - (p/'3D'/col/typ/asset/'comp/work/maya', 'workspace'), - ] - - def new_shot(p, col, seq, shot): - return [ - (p/'3D'/col, 'collection'), - (p/'3D'/col/seq, 'sequence'), - (p/'3D'/col/seq/shot, 'shot'), - (p/'3D'/col/seq/shot/'anim', 'task'), - (p/'3D'/col/seq/shot/'anim/work/maya', 'workspace'), - (p/'3D'/col/seq/shot/'light', 'task'), - (p/'3D'/col/seq/shot/'light/work/maya', 'workspace'), - (p/'3D'/col/seq/shot/'fx', 'task'), - (p/'3D'/col/seq/shot/'fx/work/maya', 'workspace'), - (p/'3D'/col/seq/shot/'comp', 'task'), - (p/'3D'/col/seq/shot/'comp/work/maya', 'workspace'), - ] - - entries = [(where, 'project')] - entries.extend(new_asset(where, 'assets', 'prop', 'prop_01')) - entries.extend(new_asset(where, 'assets', 'product', 'product_01')) - entries.extend(new_asset(where, 'assets', 'character', 'char_01')) - entries.extend(new_shot(where, 'shots', 'seq_01', 'seq_01_010')) - entries.extend(new_shot(where, 'shots', 'seq_01', 'seq_01_020')) - entries.extend(new_shot(where, 'shots', 'seq_01', 'seq_01_030')) - entries.extend(new_shot(where, 'users', 'user_01', 'user_01_010')) - entries.extend(new_shot(where, 'users', 'user_01', 'user_01_020')) - entries.extend(new_shot(where, 'users', 'user_01', 'user_01_030')) - - for path, tag in entries: - fsfs.tag(str(path), tag) - - def setup_module(): setup_api(__name__) @@ -78,23 +21,17 @@ def teardown_module(): teardown_api(__name__) -@unittest.skip('Temporarily Disabled.') def test_initial_migration(): + '''Migrate old style project.''' project_root = data_dir(__name__, 'projects', 'old_style_project') - _setup_old_project(project_root) + migrations.utils.create_old_project(project_root) api = construct.API(__name__) # Make sure our project is invalid prj = api.io.get_project('old_style_project') - assert '_id' not in prj - - try: - api.io.get_folders(prj) - assert True - except: - assert False, 'project does not need migration.' + assert 'schema_version' not in prj # Perform migration migrations.initial_migration(api, project_root) @@ -104,29 +41,31 @@ def test_initial_migration(): expected_assets = ['prop_01', 'product_01', 'char_01'] actual_assets = [] - for asset in api.io.get_assets(prj, 'asset'): + for asset in api.io.get_assets(prj, asset_type='asset'): actual_assets.append(asset['name']) assert '_id' in asset + assert asset['name'] in prj['assets'] assert set(expected_assets) == set(actual_assets) - seq = api.io.get_folder('seq_01', prj) expected_shots = ['seq_01_010', 'seq_01_020', 'seq_01_030'] actual_shots = [] - for shot in api.io.get_assets(seq, 'shot'): + for shot in api.io.get_assets(prj, group='seq_01', asset_type='shot'): actual_shots.append(shot['name']) assert '_id' in shot + assert shot['name'] in prj['assets'] assert set(expected_shots) == set(actual_shots) - seq = api.io.get_folder('user_01', prj) expected_shots = ['user_01_010', 'user_01_020', 'user_01_030'] actual_shots = [] - for shot in api.io.get_assets(seq, 'shot'): + for shot in api.io.get_assets(prj, group='user_01', asset_type='shot'): actual_shots.append(shot['name']) assert '_id' in shot + assert shot['name'] in prj['assets'] assert set(expected_shots) == set(actual_shots) def test_collect_migrations(): + '''Collect migrations from a folder.''' expected = set(['M1', 'M2', 'M3', 'M4', 'M5']) retrieved_migrations = migrations.get_migrations(MIGRATIONS_DIR) @@ -135,6 +74,7 @@ def test_collect_migrations(): def test_forward_migration(): + '''Migrate data forward.''' api = construct.API(__name__) entity = {'_type': 'test', 'schema_version': '0.0.2'} @@ -142,7 +82,7 @@ def test_forward_migration(): result = migrations.forward( api, entity, - to_version=None, # None will run forward to the latest version + to_version=None, # None will run forward to the latest version migrations_dir=MIGRATIONS_DIR, ) assert result['schema_version'] == '1.0.1' @@ -157,6 +97,7 @@ def test_forward_migration(): def test_backward_migration(): + '''Migrate date backward.''' api = construct.API(__name__) entity = {'_type': 'test', 'schema_version': '1.1.0'} diff --git a/ui_tasks.py b/ui_tasks.py index e5573e3..54072d3 100644 --- a/ui_tasks.py +++ b/ui_tasks.py @@ -27,6 +27,37 @@ def watch(ctx): watcher.start() +@task(watch) +def launcher(ctx): + '''Show the Launcher App.''' + + from construct.ui.launcher import main + main() + + +@task(watch) +def bookmarks(self): + '''Show the Bookmarks Dialog.''' + + from construct.ui.state import State + from construct.ui.dialogs import BookmarksDialog + from construct.ui.eventloop import get_event_loop + + loop = get_event_loop() + + api = construct.API() + state = State( + api=api, + context=api.get_context(), + bookmarks=api.user_cache.get('bookmarks', []), + uri=api.uri_from_context(api.get_context()), + ) + dialog = BookmarksDialog(state) + dialog.show() + + loop.start() + + @task(watch) def dialogs(ctx): '''Show a series of dialogs.''' @@ -39,7 +70,7 @@ def dialogs(ctx): notification(ctx, 'error') notification(ctx, 'success') notification(ctx, 'info') - ask(ctx, ) + ask(ctx) @task(watch)