Merge branch 'develop' into enhancement/adding-aces-1.3-ocio-support

This commit is contained in:
Jakub Ježek 2025-04-04 14:48:39 +02:00 committed by GitHub
commit 72537bb0b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
146 changed files with 9959 additions and 9960 deletions

View file

@ -21,4 +21,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: chartboost/ruff-action@v1
- uses: astral-sh/ruff-action@v1
with:
changed-files: "true"

31
.github/workflows/pr_unittests.yaml vendored Normal file
View file

@ -0,0 +1,31 @@
name: 🧐 Run Unit Tests
on:
push:
branches: [ develop ]
pull_request:
branches: [ develop ]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number}}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
jobs:
tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.9'
- name: Install requirements
run: ./tools/manage.sh create-env
- name: Run tests
run: ./tools/manage.sh run-tests

3
.gitignore vendored
View file

@ -83,3 +83,6 @@ dump.sql
mypy.ini
.github_changelog_generator
# ignore mkdocs build
site/

View file

@ -8,7 +8,6 @@ from pathlib import Path
import warnings
import click
import acre
from ayon_core import AYON_CORE_ROOT
from ayon_core.addon import AddonsManager
@ -18,6 +17,11 @@ from ayon_core.lib import (
is_running_from_build,
Logger,
)
from ayon_core.lib.env_tools import (
parse_env_variables_structure,
compute_env_variables_structure,
merge_env_variables,
)
@ -235,24 +239,19 @@ def version(build):
def _set_global_environments() -> None:
"""Set global AYON environments."""
general_env = get_general_environments()
# First resolve general environment
general_env = parse_env_variables_structure(get_general_environments())
# first resolve general environment because merge doesn't expect
# values to be list.
# TODO: switch to AYON environment functions
merged_env = acre.merge(
acre.compute(acre.parse(general_env), cleanup=False),
# Merge environments with current environments and update values
merged_env = merge_env_variables(
compute_env_variables_structure(general_env),
dict(os.environ)
)
env = acre.compute(
merged_env,
cleanup=False
)
env = compute_env_variables_structure(merged_env)
os.environ.clear()
os.environ.update(env)
# Hardcoded default values
os.environ["PYBLISH_GUI"] = "pyblish_pype"
# Change scale factor only if is not set
if "QT_AUTO_SCREEN_SCALE_FACTOR" not in os.environ:
os.environ["QT_AUTO_SCREEN_SCALE_FACTOR"] = "1"
@ -263,8 +262,8 @@ def _set_addons_environments(addons_manager):
# Merge environments with current environments and update values
if module_envs := addons_manager.collect_global_environments():
parsed_envs = acre.parse(module_envs)
env = acre.merge(parsed_envs, dict(os.environ))
parsed_envs = parse_env_variables_structure(module_envs)
env = merge_env_variables(parsed_envs, dict(os.environ))
os.environ.clear()
os.environ.update(env)
@ -290,8 +289,6 @@ def main(*args, **kwargs):
split_paths = python_path.split(os.pathsep)
additional_paths = [
# add AYON tools for 'pyblish_pype'
os.path.join(AYON_CORE_ROOT, "tools"),
# add common AYON vendor
# (common for multiple Python interpreter versions)
os.path.join(AYON_CORE_ROOT, "vendor", "python")

View file

@ -9,6 +9,7 @@ from .local_settings import (
AYONSettingsRegistry,
get_launcher_local_dir,
get_launcher_storage_dir,
get_addons_resources_dir,
get_local_site_id,
get_ayon_username,
)
@ -142,6 +143,7 @@ __all__ = [
"AYONSettingsRegistry",
"get_launcher_local_dir",
"get_launcher_storage_dir",
"get_addons_resources_dir",
"get_local_site_id",
"get_ayon_username",

View file

@ -550,29 +550,38 @@ class EnumDef(AbstractAttrDef):
passed items or list of values for multiselection.
multiselection (Optional[bool]): If True, multiselection is allowed.
Output is list of selected items.
placeholder (Optional[str]): Placeholder for UI purposes, only for
multiselection enumeration.
"""
type = "enum"
type_attributes = [
"multiselection",
"placeholder",
]
def __init__(
self,
key: str,
items: "EnumItemsInputType",
default: "Union[str, List[Any]]" = None,
multiselection: Optional[bool] = False,
placeholder: Optional[str] = None,
**kwargs
):
if not items:
raise ValueError((
"Empty 'items' value. {} must have"
if multiselection is None:
multiselection = False
if not items and not multiselection:
raise ValueError(
f"Empty 'items' value. {self.__class__.__name__} must have"
" defined values on initialization."
).format(self.__class__.__name__))
)
items = self.prepare_enum_items(items)
item_values = [item["value"] for item in items]
item_values_set = set(item_values)
if multiselection is None:
multiselection = False
if multiselection:
if default is None:
@ -587,6 +596,7 @@ class EnumDef(AbstractAttrDef):
self.items: List["EnumItemDict"] = items
self._item_values: Set[Any] = item_values_set
self.multiselection: bool = multiselection
self.placeholder: Optional[str] = placeholder
def convert_value(self, value):
if not self.multiselection:
@ -612,7 +622,6 @@ class EnumDef(AbstractAttrDef):
def serialize(self):
data = super().serialize()
data["items"] = copy.deepcopy(self.items)
data["multiselection"] = self.multiselection
return data
@staticmethod

View file

@ -1,7 +1,34 @@
from __future__ import annotations
import os
import re
import platform
import typing
import collections
from string import Formatter
from typing import Optional
if typing.TYPE_CHECKING:
from typing import Union, Literal
PlatformName = Literal["windows", "linux", "darwin"]
EnvValue = Union[str, list[str], dict[str, str], dict[str, list[str]]]
def env_value_to_bool(env_key=None, value=None, default=False):
class CycleError(ValueError):
"""Raised when a cycle is detected in dynamic env variables compute."""
pass
class DynamicKeyClashError(Exception):
"""Raised when dynamic key clashes with an existing key."""
pass
def env_value_to_bool(
env_key: Optional[str] = None,
value: Optional[str] = None,
default: bool = False,
) -> bool:
"""Convert environment variable value to boolean.
Function is based on value of the environemt variable. Value is lowered
@ -11,6 +38,7 @@ def env_value_to_bool(env_key=None, value=None, default=False):
bool: If value match to one of ["true", "yes", "1"] result if True
but if value match to ["false", "no", "0"] result is False else
default value is returned.
"""
if value is None and env_key is None:
return default
@ -27,18 +55,23 @@ def env_value_to_bool(env_key=None, value=None, default=False):
return default
def get_paths_from_environ(env_key=None, env_value=None, return_first=False):
def get_paths_from_environ(
env_key: Optional[str] = None,
env_value: Optional[str] = None,
return_first: bool = False,
) -> Optional[Union[str, list[str]]]:
"""Return existing paths from specific environment variable.
Args:
env_key (str): Environment key where should look for paths.
env_value (str): Value of environment variable. Argument `env_key` is
skipped if this argument is entered.
env_key (Optional[str]): Environment key where should look for paths.
env_value (Optional[str]): Value of environment variable.
Argument `env_key` is skipped if this argument is entered.
return_first (bool): Return first found value or return list of found
paths. `None` or empty list returned if nothing found.
Returns:
str, list, None: Result of found path/s.
Optional[Union[str, list[str]]]: Result of found path/s.
"""
existing_paths = []
if not env_key and not env_value:
@ -69,3 +102,225 @@ def get_paths_from_environ(env_key=None, env_value=None, return_first=False):
return None
# Return all existing paths from environment variable
return existing_paths
def parse_env_variables_structure(
env: dict[str, EnvValue],
platform_name: Optional[PlatformName] = None
) -> dict[str, str]:
"""Parse environment for platform-specific values and paths as lists.
Args:
env (dict): The source environment to read.
platform_name (Optional[PlatformName]): Name of platform to parse for.
Defaults to current platform.
Returns:
dict: The flattened environment for a platform.
"""
if platform_name is None:
platform_name = platform.system().lower()
# Separator based on OS 'os.pathsep' is ';' on Windows and ':' on Unix
sep = ";" if platform_name == "windows" else ":"
result = {}
for variable, value in env.items():
# Platform specific values
if isinstance(value, dict):
value = value.get(platform_name)
# Allow to have lists as values in the tool data
if isinstance(value, (list, tuple)):
value = sep.join(value)
if not value:
continue
if not isinstance(value, str):
raise TypeError(f"Expected 'str' got '{type(value)}'")
result[variable] = value
return result
def _topological_sort(
dependencies: dict[str, set[str]]
) -> tuple[list[str], list[str]]:
"""Sort values subject to dependency constraints.
Args:
dependencies (dict[str, set[str]): Mapping of environment variable
keys to a set of keys they depend on.
Returns:
tuple[list[str], list[str]]: A tuple of two lists. The first list
contains the ordered keys in which order should be environment
keys filled, the second list contains the keys that would cause
cyclic fill of values.
"""
num_heads = collections.defaultdict(int) # num arrows pointing in
tails = collections.defaultdict(list) # list of arrows going out
heads = [] # unique list of heads in order first seen
for head, tail_values in dependencies.items():
for tail_value in tail_values:
num_heads[tail_value] += 1
if head not in tails:
heads.append(head)
tails[head].append(tail_value)
ordered = [head for head in heads if head not in num_heads]
for head in ordered:
for tail in tails[head]:
num_heads[tail] -= 1
if not num_heads[tail]:
ordered.append(tail)
cyclic = [tail for tail, heads in num_heads.items() if heads]
return ordered, cyclic
class _PartialFormatDict(dict):
"""This supports partial formatting.
Missing keys are replaced with the return value of __missing__.
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._missing_template: str = "{{{key}}}"
def set_missing_template(self, template: str):
self._missing_template = template
def __missing__(self, key: str) -> str:
return self._missing_template.format(key=key)
def _partial_format(
value: str,
data: dict[str, str],
missing_template: Optional[str] = None,
) -> str:
"""Return string `s` formatted by `data` allowing a partial format
Arguments:
value (str): The string that will be formatted
data (dict): The dictionary used to format with.
missing_template (Optional[str]): The template to use when a key is
missing from the data. If `None`, the key will remain unformatted.
Example:
>>> _partial_format("{d} {a} {b} {c} {d}", {'b': "and", 'd': "left"})
'left {a} and {c} left'
"""
mapping = _PartialFormatDict(**data)
if missing_template is not None:
mapping.set_missing_template(missing_template)
formatter = Formatter()
try:
output = formatter.vformat(value, (), mapping)
except Exception:
r_token = re.compile(r"({.*?})")
output = value
for match in re.findall(r_token, value):
try:
output = re.sub(match, match.format(**data), output)
except (KeyError, ValueError, IndexError):
continue
return output
def compute_env_variables_structure(
env: dict[str, str],
fill_dynamic_keys: bool = True,
) -> dict[str, str]:
"""Compute the result from recursive dynamic environment.
Note: Keys that are not present in the data will remain unformatted as the
original keys. So they can be formatted against the current user
environment when merging. So {"A": "{key}"} will remain {key} if not
present in the dynamic environment.
"""
env = env.copy()
# Collect dependencies
dependencies = collections.defaultdict(set)
for key, value in env.items():
dependent_keys = re.findall("{(.+?)}", value)
for dependent_key in dependent_keys:
# Ignore reference to itself or key is not in env
if dependent_key != key and dependent_key in env:
dependencies[key].add(dependent_key)
ordered, cyclic = _topological_sort(dependencies)
# Check cycle
if cyclic:
raise CycleError(f"A cycle is detected on: {cyclic}")
# Format dynamic values
for key in reversed(ordered):
if key in env:
if not isinstance(env[key], str):
continue
data = env.copy()
data.pop(key) # format without itself
env[key] = _partial_format(env[key], data=data)
# Format dynamic keys
if fill_dynamic_keys:
formatted = {}
for key, value in env.items():
if not isinstance(value, str):
formatted[key] = value
continue
new_key = _partial_format(key, data=env)
if new_key in formatted:
raise DynamicKeyClashError(
f"Key clashes on: {new_key} (source: {key})"
)
formatted[new_key] = value
env = formatted
return env
def merge_env_variables(
src_env: dict[str, str],
dst_env: dict[str, str],
missing_template: Optional[str] = None,
) -> dict[str, str]:
"""Merge the tools environment with the 'current_env'.
This finalizes the join with a current environment by formatting the
remainder of dynamic variables with that from the current environment.
Remaining missing variables result in an empty value.
Args:
src_env (dict): The dynamic environment
dst_env (dict): The target environment variables mapping to merge
the dynamic environment into.
missing_template (str): Argument passed to '_partial_format' during
merging. `None` should keep missing keys unchanged.
Returns:
dict[str, str]: The resulting environment after the merge.
"""
result = dst_env.copy()
for key, value in src_env.items():
result[key] = _partial_format(
str(value), dst_env, missing_template
)
return result

View file

@ -108,21 +108,29 @@ def run_subprocess(*args, **kwargs):
| getattr(subprocess, "CREATE_NO_WINDOW", 0)
)
# Escape parentheses for bash
# Escape special characters in certain shells
if (
kwargs.get("shell") is True
and len(args) == 1
and isinstance(args[0], str)
and os.getenv("SHELL") in ("/bin/bash", "/bin/sh")
):
new_arg = (
args[0]
.replace("(", "\\(")
.replace(")", "\\)")
)
args = (new_arg, )
# Escape parentheses for bash
if os.getenv("SHELL") in ("/bin/bash", "/bin/sh"):
new_arg = (
args[0]
.replace("(", "\\(")
.replace(")", "\\)")
)
args = (new_arg,)
# Escape & on Windows in shell with `cmd.exe` using ^&
elif (
platform.system().lower() == "windows"
and os.getenv("COMSPEC").endswith("cmd.exe")
):
new_arg = args[0].replace("&", "^&")
args = (new_arg, )
# Get environents from kwarg or use current process environments if were
# Get environments from kwarg or use current process environments if were
# not passed.
env = kwargs.get("env") or os.environ
# Make sure environment contains only strings

View file

@ -9,7 +9,7 @@ from datetime import datetime
from abc import ABC, abstractmethod
from functools import lru_cache
import appdirs
import platformdirs
import ayon_api
_PLACEHOLDER = object()
@ -17,7 +17,7 @@ _PLACEHOLDER = object()
def _get_ayon_appdirs(*args):
return os.path.join(
appdirs.user_data_dir("AYON", "Ynput"),
platformdirs.user_data_dir("AYON", "Ynput"),
*args
)
@ -96,6 +96,30 @@ def get_launcher_local_dir(*subdirs: str) -> str:
return os.path.join(storage_dir, *subdirs)
def get_addons_resources_dir(addon_name: str, *args) -> str:
"""Get directory for storing resources for addons.
Some addons might need to store ad-hoc resources that are not part of
addon client package (e.g. because of size). Studio might define
dedicated directory to store them with 'AYON_ADDONS_RESOURCES_DIR'
environment variable. By default, is used 'addons_resources' in
launcher storage (might be shared across platforms).
Args:
addon_name (str): Addon name.
*args (str): Subfolders in resources directory.
Returns:
str: Path to resources directory.
"""
addons_resources_dir = os.getenv("AYON_ADDONS_RESOURCES_DIR")
if not addons_resources_dir:
addons_resources_dir = get_launcher_storage_dir("addons_resources")
return os.path.join(addons_resources_dir, addon_name, *args)
class AYONSecureRegistry:
"""Store information using keyring.

View file

@ -587,8 +587,8 @@ class FormattingPart:
if sub_key < 0:
sub_key = len(value) + sub_key
invalid = 0 > sub_key < len(data)
if invalid:
valid = 0 <= sub_key < len(value)
if not valid:
used_keys.append(sub_key)
missing_key = True
break

View file

@ -1,17 +0,0 @@
# Deprecated file
# - the file container 'WeakMethod' implementation for Python 2 which is not
# needed anymore.
import warnings
import weakref
WeakMethod = weakref.WeakMethod
warnings.warn(
(
"'ayon_core.lib.python_2_comp' is deprecated."
"Please use 'weakref.WeakMethod'."
),
DeprecationWarning,
stacklevel=2
)

View file

@ -1,6 +1,8 @@
"""Tools for working with python modules and classes."""
import os
import sys
import types
from typing import Optional
import importlib
import inspect
import logging
@ -8,13 +10,22 @@ import logging
log = logging.getLogger(__name__)
def import_filepath(filepath, module_name=None):
def import_filepath(
filepath: str,
module_name: Optional[str] = None,
sys_module_name: Optional[str] = None) -> types.ModuleType:
"""Import python file as python module.
Args:
filepath (str): Path to python file.
module_name (str): Name of loaded module. Only for Python 3. By default
is filled with filename of filepath.
sys_module_name (str): Name of module in `sys.modules` where to store
loaded module. By default is None so module is not added to
`sys.modules`.
Todo (antirotor): We should add the module to the sys.modules always but
we need to be careful about it and test it properly.
"""
if module_name is None:
@ -28,6 +39,9 @@ def import_filepath(filepath, module_name=None):
module_loader = importlib.machinery.SourceFileLoader(
module_name, filepath
)
# only add to sys.modules if requested
if sys_module_name:
sys.modules[sys_module_name] = module
module_loader.exec_module(module)
return module
@ -126,7 +140,8 @@ def classes_from_module(superclass, module):
return classes
def import_module_from_dirpath(dirpath, folder_name, dst_module_name=None):
def import_module_from_dirpath(
dirpath, folder_name, dst_module_name=None):
"""Import passed directory as a python module.
Imported module can be assigned as a child attribute of already loaded
@ -193,7 +208,7 @@ def is_func_signature_supported(func, *args, **kwargs):
Notes:
This does NOT check if the function would work with passed arguments
only if they can be passed in. If function have *args, **kwargs
in paramaters, this will always return 'True'.
in parameters, this will always return 'True'.
Example:
>>> def my_function(my_number):

View file

@ -53,7 +53,7 @@ IMAGE_EXTENSIONS = {
".kra", ".logluv", ".mng", ".miff", ".nrrd", ".ora",
".pam", ".pbm", ".pgm", ".ppm", ".pnm", ".pcx", ".pgf",
".pictor", ".png", ".psd", ".psb", ".psp", ".qtvr",
".ras", ".rgbe", ".sgi", ".tga",
".ras", ".rgbe", ".sgi", ".sxr", ".tga",
".tif", ".tiff", ".tiff/ep", ".tiff/it", ".ufo", ".ufp",
".wbmp", ".webp", ".xr", ".xt", ".xbm", ".xcf", ".xpm", ".xwd"
}

View file

@ -29,6 +29,7 @@ from ayon_core.lib.events import QueuedEventSystem
from ayon_core.lib.attribute_definitions import get_default_values
from ayon_core.host import IPublishHost, IWorkfileHost
from ayon_core.pipeline import Anatomy
from ayon_core.pipeline.template_data import get_template_data
from ayon_core.pipeline.plugin_discover import DiscoverResult
from .exceptions import (
@ -480,6 +481,36 @@ class CreateContext:
self.get_current_project_name())
return self._current_project_settings
def get_template_data(
self, folder_path: Optional[str], task_name: Optional[str]
) -> Dict[str, Any]:
"""Prepare template data for given context.
Method is using cached entities and settings to prepare template data.
Args:
folder_path (Optional[str]): Folder path.
task_name (Optional[str]): Task name.
Returns:
dict[str, Any]: Template data.
"""
project_entity = self.get_current_project_entity()
folder_entity = task_entity = None
if folder_path:
folder_entity = self.get_folder_entity(folder_path)
if task_name and folder_entity:
task_entity = self.get_task_entity(folder_path, task_name)
return get_template_data(
project_entity,
folder_entity,
task_entity,
host_name=self.host_name,
settings=self.get_current_project_settings(),
)
@property
def context_has_changed(self):
"""Host context has changed.
@ -724,11 +755,19 @@ class CreateContext:
).format(creator_class.host_name, self.host_name))
continue
creator = creator_class(
project_settings,
self,
self.headless
)
# TODO report initialization error
try:
creator = creator_class(
project_settings,
self,
self.headless
)
except Exception:
self.log.error(
f"Failed to initialize plugin: {creator_class}",
exc_info=True
)
continue
if not creator.enabled:
disabled_creators[creator_identifier] = creator
@ -800,7 +839,7 @@ class CreateContext:
publish_attributes.update(output)
for plugin in self.plugins_with_defs:
attr_defs = plugin.get_attr_defs_for_context (self)
attr_defs = plugin.get_attr_defs_for_context(self)
if not attr_defs:
continue
self._publish_attributes.set_publish_plugin_attr_defs(
@ -1220,50 +1259,6 @@ class CreateContext:
with self._bulk_context("add", sender) as bulk_info:
yield bulk_info
# Set publish attributes before bulk context is exited
for instance in bulk_info.get_data():
publish_attributes = instance.publish_attributes
# Prepare publish plugin attributes and set it on instance
for plugin in self.plugins_with_defs:
try:
if is_func_signature_supported(
plugin.convert_attribute_values, self, instance
):
plugin.convert_attribute_values(self, instance)
elif plugin.__instanceEnabled__:
output = plugin.convert_attribute_values(
publish_attributes
)
if output:
publish_attributes.update(output)
except Exception:
self.log.error(
"Failed to convert attribute values of"
f" plugin '{plugin.__name__}'",
exc_info=True
)
for plugin in self.plugins_with_defs:
attr_defs = None
try:
attr_defs = plugin.get_attr_defs_for_instance(
self, instance
)
except Exception:
self.log.error(
"Failed to get attribute definitions"
f" from plugin '{plugin.__name__}'.",
exc_info=True
)
if not attr_defs:
continue
instance.set_publish_plugin_attr_defs(
plugin.__name__, attr_defs
)
@contextmanager
def bulk_instances_collection(self, sender=None):
"""DEPRECATED use 'bulk_add_instances' instead."""
@ -2212,6 +2207,50 @@ class CreateContext:
if not instances_to_validate:
return
# Set publish attributes before bulk callbacks are triggered
for instance in instances_to_validate:
publish_attributes = instance.publish_attributes
# Prepare publish plugin attributes and set it on instance
for plugin in self.plugins_with_defs:
try:
if is_func_signature_supported(
plugin.convert_attribute_values, self, instance
):
plugin.convert_attribute_values(self, instance)
elif plugin.__instanceEnabled__:
output = plugin.convert_attribute_values(
publish_attributes
)
if output:
publish_attributes.update(output)
except Exception:
self.log.error(
"Failed to convert attribute values of"
f" plugin '{plugin.__name__}'",
exc_info=True
)
for plugin in self.plugins_with_defs:
attr_defs = None
try:
attr_defs = plugin.get_attr_defs_for_instance(
self, instance
)
except Exception:
self.log.error(
"Failed to get attribute definitions"
f" from plugin '{plugin.__name__}'.",
exc_info=True
)
if not attr_defs:
continue
instance.set_publish_plugin_attr_defs(
plugin.__name__, attr_defs
)
# Cache folder and task entities for all instances at once
self.get_instances_context_info(instances_to_validate)

View file

@ -562,6 +562,10 @@ class BaseCreator(ABC):
instance
)
cur_project_name = self.create_context.get_current_project_name()
if not project_entity and project_name == cur_project_name:
project_entity = self.create_context.get_current_project_entity()
return get_product_name(
project_name,
task_name,
@ -858,18 +862,30 @@ class Creator(BaseCreator):
["CollectAnatomyInstanceData"]
["follow_workfile_version"]
)
follow_version_hosts = (
publish_settings
["CollectSceneVersion"]
["hosts"]
)
current_host = create_ctx.host.name
follow_workfile_version = (
follow_workfile_version and
current_host in follow_version_hosts
)
# Gather version number provided from the instance.
current_workfile = create_ctx.get_current_workfile_path()
version = instance.get("version")
# If follow workfile, gather version from workfile path.
if version is None and follow_workfile_version:
current_workfile = self.create_context.get_current_workfile_path()
if version is None and follow_workfile_version and current_workfile:
workfile_version = get_version_from_path(current_workfile)
version = int(workfile_version)
if workfile_version is not None:
version = int(workfile_version)
# Fill-up version with next version available.
elif version is None:
if version is None:
versions = self.get_next_versions_for_instances(
[instance]
)

View file

@ -1,6 +1,7 @@
import os
import re
import clique
import math
import opentimelineio as otio
from opentimelineio import opentime as _ot
@ -256,8 +257,14 @@ def remap_range_on_file_sequence(otio_clip, otio_range):
rate=available_range_rate,
).to_frames()
# e.g.:
# duration = 10 frames at 24fps
# if frame_in = 1001 then
# frame_out = 1010
offset_duration = max(0, otio_range.duration.to_frames() - 1)
frame_out = otio.opentime.RationalTime.from_frames(
frame_in + otio_range.duration.to_frames() - 1,
frame_in + offset_duration,
rate=available_range_rate,
).to_frames()
@ -337,8 +344,6 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end):
# modifiers
time_scalar = 1.
offset_in = 0
offset_out = 0
time_warp_nodes = []
# Check for speed effects and adjust playback speed accordingly
@ -369,24 +374,15 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end):
tw_node.update(metadata)
tw_node["lookup"] = list(lookup)
# get first and last frame offsets
offset_in += lookup[0]
offset_out += lookup[-1]
# add to timewarp nodes
time_warp_nodes.append(tw_node)
# multiply by time scalar
offset_in *= time_scalar
offset_out *= time_scalar
# scale handles
handle_start *= abs(time_scalar)
handle_end *= abs(time_scalar)
# flip offset and handles if reversed speed
if time_scalar < 0:
offset_in, offset_out = offset_out, offset_in
handle_start, handle_end = handle_end, handle_start
# If media source is an image sequence, returned
@ -395,17 +391,19 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end):
if is_input_sequence:
src_in = conformed_source_range.start_time
src_duration = conformed_source_range.duration
offset_in = otio.opentime.RationalTime(offset_in, rate=src_in.rate)
offset_duration = otio.opentime.RationalTime(
offset_out,
rate=src_duration.rate
src_duration = math.ceil(
otio_clip.source_range.duration.value
* abs(time_scalar)
)
retimed_duration = otio.opentime.RationalTime(
src_duration,
otio_clip.source_range.duration.rate
)
retimed_duration = retimed_duration.rescaled_to(src_in.rate)
trim_range = otio.opentime.TimeRange(
start_time=src_in + offset_in,
duration=src_duration + offset_duration
start_time=src_in,
duration=retimed_duration,
)
# preserve discrete frame numbers
@ -418,18 +416,92 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end):
else:
# compute retimed range
media_in_trimmed = conformed_source_range.start_time.value + offset_in
media_out_trimmed = media_in_trimmed + (
(
conformed_source_range.duration.value
* abs(time_scalar)
+ offset_out
) - 1
media_in_trimmed = conformed_source_range.start_time.value
offset_duration = (
conformed_source_range.duration.value
* abs(time_scalar)
)
# Offset duration by 1 for media out frame
# - only if duration is not single frame (start frame != end frame)
if offset_duration > 0:
offset_duration -= 1
media_out_trimmed = media_in_trimmed + offset_duration
media_in = available_range.start_time.value
media_out = available_range.end_time_inclusive().value
if time_warp_nodes:
# Naive approach: Resolve consecutive timewarp(s) on range,
# then check if plate range has to be extended beyond source range.
in_frame = media_in_trimmed
frame_range = [in_frame]
for _ in range(otio_clip.source_range.duration.to_frames() - 1):
in_frame += time_scalar
frame_range.append(in_frame)
# Different editorial DCC might have different TimeWarp logic.
# The following logic assumes that the "lookup" list values are
# frame offsets relative to the current source frame number.
#
# media_source_range |______1_____|______2______|______3______|
#
# media_retimed_range |______2_____|______2______|______3______|
#
# TimeWarp lookup +1 0 0
for tw_idx, tw in enumerate(time_warp_nodes):
for idx, frame_number in enumerate(frame_range):
# First timewarp, apply on media range
if tw_idx == 0:
frame_range[idx] = round(
frame_number +
(tw["lookup"][idx] * time_scalar)
)
# Consecutive timewarp, apply on the previous result
else:
new_idx = round(idx + tw["lookup"][idx])
if 0 <= new_idx < len(frame_range):
frame_range[idx] = frame_range[new_idx]
continue
# TODO: implementing this would need to actually have
# retiming engine resolve process within AYON,
# resolving wraps as curves, then projecting
# those into the previous media_range.
raise NotImplementedError(
"Unsupported consecutive timewarps "
"(out of computed range)"
)
# adjust range if needed
media_in_trimmed_before_tw = media_in_trimmed
media_in_trimmed = max(min(frame_range), media_in)
media_out_trimmed = min(max(frame_range), media_out)
# If TimeWarp changes the first frame of the soure range,
# we need to offset the first TimeWarp values accordingly.
#
# expected_range |______2_____|______2______|______3______|
#
# EDITORIAL
# media_source_range |______1_____|______2______|______3______|
#
# TimeWarp lookup +1 0 0
#
# EXTRACTED PLATE
# plate_range |______2_____|______3______|_ _ _ _ _ _ _|
#
# expected TimeWarp 0 -1 -1
if media_in_trimmed != media_in_trimmed_before_tw:
offset = media_in_trimmed_before_tw - media_in_trimmed
offset *= 1.0 / time_scalar
time_warp_nodes[0]["lookup"] = [
value + offset
for value in time_warp_nodes[0]["lookup"]
]
# adjust available handles if needed
if (media_in_trimmed - media_in) < handle_start:
handle_start = max(0, media_in_trimmed - media_in)
@ -448,16 +520,16 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end):
"retime": True,
"speed": time_scalar,
"timewarps": time_warp_nodes,
"handleStart": int(handle_start),
"handleEnd": int(handle_end)
"handleStart": math.ceil(handle_start),
"handleEnd": math.ceil(handle_end)
}
}
returning_dict = {
"mediaIn": media_in_trimmed,
"mediaOut": media_out_trimmed,
"handleStart": int(handle_start),
"handleEnd": int(handle_end),
"handleStart": math.ceil(handle_start),
"handleEnd": math.ceil(handle_end),
"speed": time_scalar
}

View file

@ -1,3 +1,4 @@
from __future__ import annotations
import copy
import os
import re
@ -247,7 +248,8 @@ def create_skeleton_instance(
"useSequenceForReview": data.get("useSequenceForReview", True),
# map inputVersions `ObjectId` -> `str` so json supports it
"inputVersions": list(map(str, data.get("inputVersions", []))),
"colorspace": data.get("colorspace")
"colorspace": data.get("colorspace"),
"hasExplicitFrames": data.get("hasExplicitFrames")
}
if data.get("renderlayer"):
@ -324,8 +326,8 @@ def prepare_representations(
skip_integration_repre_list (list): exclude specific extensions,
do_not_add_review (bool): explicitly skip review
color_managed_plugin (publish.ColormanagedPyblishPluginMixin)
frames_to_render (str): implicit or explicit range of frames to render
this value is sent to Deadline in JobInfo.Frames
frames_to_render (str | None): implicit or explicit range of frames
to render this value is sent to Deadline in JobInfo.Frames
Returns:
list of representations
@ -337,7 +339,7 @@ def prepare_representations(
log = Logger.get_logger("farm_publishing")
if frames_to_render is not None:
frames_to_render = _get_real_frames_to_render(frames_to_render)
frames_to_render = convert_frames_str_to_list(frames_to_render)
else:
# Backwards compatibility for older logic
frame_start = int(skeleton_data.get("frameStartHandle"))
@ -386,17 +388,21 @@ def prepare_representations(
frame_start -= 1
frames_to_render.insert(0, frame_start)
files = _get_real_files_to_render(collection, frames_to_render)
filenames = [
os.path.basename(filepath)
for filepath in _get_real_files_to_render(
collection, frames_to_render
)
]
# explicitly disable review by user
preview = preview and not do_not_add_review
rep = {
"name": ext,
"ext": ext,
"files": files,
"files": filenames,
"stagingDir": staging,
"frameStart": frame_start,
"frameEnd": frame_end,
# If expectedFile are absolute, we need only filenames
"stagingDir": staging,
"fps": skeleton_data.get("fps"),
"tags": ["review"] if preview else [],
}
@ -475,21 +481,45 @@ def prepare_representations(
return representations
def _get_real_frames_to_render(frames):
"""Returns list of frames that should be rendered.
def convert_frames_str_to_list(frames: str) -> list[int]:
"""Convert frames definition string to frames.
Handles formats as:
>>> convert_frames_str_to_list('1001')
[1001]
>>> convert_frames_str_to_list('1002,1004')
[1002, 1004]
>>> convert_frames_str_to_list('1003-1005')
[1003, 1004, 1005]
>>> convert_frames_str_to_list('1001-1021x5')
[1001, 1006, 1011, 1016, 1021]
Args:
frames (str): String with frames definition.
Returns:
list[int]: List of frames.
Artists could want to selectively render only particular frames
"""
frames_to_render = []
step_pattern = re.compile(r"(?:step|by|every|x|:)(\d+)$")
output = []
step = 1
for frame in frames.split(","):
if "-" in frame:
splitted = frame.split("-")
frames_to_render.extend(
range(int(splitted[0]), int(splitted[1])+1))
frame_start, frame_end = frame.split("-")
match = step_pattern.findall(frame_end)
if match:
step = int(match[0])
frame_end = re.sub(step_pattern, "", frame_end)
output.extend(
range(int(frame_start), int(frame_end) + 1, step)
)
else:
frames_to_render.append(int(frame))
frames_to_render.sort()
return frames_to_render
output.append(int(frame))
output.sort()
return output
def _get_real_files_to_render(collection, frames_to_render):
@ -502,22 +532,23 @@ def _get_real_files_to_render(collection, frames_to_render):
This range would override and filter previously prepared expected files
from DCC.
Example:
>>> expected_files = clique.parse([
>>> "foo_v01.0001.exr",
>>> "foo_v01.0002.exr",
>>> ])
>>> frames_to_render = [1]
>>> _get_real_files_to_render(expected_files, frames_to_render)
["foo_v01.0001.exr"]
Args:
collection (clique.Collection): absolute paths
frames_to_render (list[int]): of int 1001
Returns:
(list[str])
list[str]: absolute paths of files to be rendered
Example:
--------
expectedFiles = [
"foo_v01.0001.exr",
"foo_v01.0002.exr",
]
frames_to_render = 1
>>
["foo_v01.0001.exr"] - only explicitly requested frame returned
"""
included_frames = set(collection.indexes).intersection(frames_to_render)
real_collection = clique.Collection(
@ -526,13 +557,17 @@ def _get_real_files_to_render(collection, frames_to_render):
collection.padding,
indexes=included_frames
)
real_full_paths = list(real_collection)
return [os.path.basename(file_url) for file_url in real_full_paths]
return list(real_collection)
def create_instances_for_aov(instance, skeleton, aov_filter,
skip_integration_repre_list,
do_not_add_review):
def create_instances_for_aov(
instance,
skeleton,
aov_filter,
skip_integration_repre_list,
do_not_add_review,
frames_to_render=None
):
"""Create instances from AOVs.
This will create new pyblish.api.Instances by going over expected
@ -544,6 +579,7 @@ def create_instances_for_aov(instance, skeleton, aov_filter,
aov_filter (dict): AOV filter.
skip_integration_repre_list (list): skip
do_not_add_review (bool): Explicitly disable reviews
frames_to_render (str | None): Frames to render.
Returns:
list of pyblish.api.Instance: Instances created from
@ -590,7 +626,8 @@ def create_instances_for_aov(instance, skeleton, aov_filter,
aov_filter,
additional_color_data,
skip_integration_repre_list,
do_not_add_review
do_not_add_review,
frames_to_render
)
@ -719,8 +756,15 @@ def get_product_name_and_group_from_template(
return resulting_product_name, resulting_group_name
def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data,
skip_integration_repre_list, do_not_add_review):
def _create_instances_for_aov(
instance,
skeleton,
aov_filter,
additional_data,
skip_integration_repre_list,
do_not_add_review,
frames_to_render
):
"""Create instance for each AOV found.
This will create new instance for every AOV it can detect in expected
@ -734,7 +778,8 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data,
skip_integration_repre_list (list): list of extensions that shouldn't
be published
do_not_add_review (bool): explicitly disable review
frames_to_render (str | None): implicit or explicit range of
frames to render this value is sent to Deadline in JobInfo.Frames
Returns:
list of instances
@ -754,10 +799,23 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data,
# go through AOVs in expected files
for aov, files in expected_files[0].items():
collected_files = _collect_expected_files_for_aov(files)
first_filepath = collected_files
if isinstance(first_filepath, (list, tuple)):
first_filepath = first_filepath[0]
staging_dir = os.path.dirname(first_filepath)
expected_filepath = collected_files
if isinstance(collected_files, (list, tuple)):
expected_filepath = collected_files[0]
if (
frames_to_render is not None
and isinstance(collected_files, (list, tuple)) # not single file
):
aov_frames_to_render = convert_frames_str_to_list(frames_to_render)
collections, _ = clique.assemble(collected_files)
collected_files = _get_real_files_to_render(
collections[0], aov_frames_to_render)
else:
frame_start = int(skeleton.get("frameStartHandle"))
frame_end = int(skeleton.get("frameEndHandle"))
aov_frames_to_render = list(range(frame_start, frame_end + 1))
dynamic_data = {
"aov": aov,
@ -768,7 +826,7 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data,
# TODO: this must be changed to be more robust. Any coincidence
# of camera name in the file path will be considered as
# camera name. This is not correct.
camera = [cam for cam in cameras if cam in expected_filepath]
camera = [cam for cam in cameras if cam in first_filepath]
# Is there just one camera matching?
# TODO: this is not true, we can have multiple cameras in the scene
@ -813,10 +871,8 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data,
dynamic_data=dynamic_data
)
staging = os.path.dirname(expected_filepath)
try:
staging = remap_source(staging, anatomy)
staging_dir = remap_source(staging_dir, anatomy)
except ValueError as e:
log.warning(e)
@ -824,7 +880,7 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data,
app = os.environ.get("AYON_HOST_NAME", "")
render_file_name = os.path.basename(expected_filepath)
render_file_name = os.path.basename(first_filepath)
aov_patterns = aov_filter
@ -881,10 +937,10 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data,
"name": ext,
"ext": ext,
"files": collected_files,
"frameStart": int(skeleton["frameStartHandle"]),
"frameEnd": int(skeleton["frameEndHandle"]),
"frameStart": aov_frames_to_render[0],
"frameEnd": aov_frames_to_render[-1],
# If expectedFile are absolute, we need only filenames
"stagingDir": staging,
"stagingDir": staging_dir,
"fps": new_instance.get("fps"),
"tags": ["review"] if preview else [],
"colorspaceData": {

View file

@ -13,15 +13,7 @@ from .utils import get_representation_path_from_context
class LoaderPlugin(list):
"""Load representation into host application
Arguments:
context (dict): avalon-core:context-1.0
.. versionadded:: 4.0
This class was introduced
"""
"""Load representation into host application"""
product_types = set()
representations = set()

View file

@ -1,6 +1,8 @@
from __future__ import annotations
import os
import re
import json
from typing import Any, Union
from ayon_core.settings import get_project_settings
from ayon_core.lib import Logger
@ -9,7 +11,7 @@ from .anatomy import Anatomy
from .template_data import get_project_template_data
def concatenate_splitted_paths(split_paths, anatomy):
def concatenate_splitted_paths(split_paths, anatomy: Anatomy):
log = Logger.get_logger("concatenate_splitted_paths")
pattern_array = re.compile(r"\[.*\]")
output = []
@ -47,7 +49,7 @@ def concatenate_splitted_paths(split_paths, anatomy):
return output
def fill_paths(path_list, anatomy):
def fill_paths(path_list: list[str], anatomy: Anatomy):
format_data = get_project_template_data(project_name=anatomy.project_name)
format_data["root"] = anatomy.roots
filled_paths = []
@ -59,7 +61,7 @@ def fill_paths(path_list, anatomy):
return filled_paths
def create_project_folders(project_name, basic_paths=None):
def create_project_folders(project_name: str, basic_paths=None):
log = Logger.get_logger("create_project_folders")
anatomy = Anatomy(project_name)
if basic_paths is None:
@ -80,8 +82,19 @@ def create_project_folders(project_name, basic_paths=None):
os.makedirs(path)
def _list_path_items(folder_structure):
def _list_path_items(
folder_structure: Union[dict[str, Any], list[str]]):
output = []
# Allow leaf folders of the `project_folder_structure` to use a list of
# strings instead of a dictionary of keys with empty values.
if isinstance(folder_structure, list):
if not all(isinstance(item, str) for item in folder_structure):
raise ValueError(
f"List items must all be strings. Got: {folder_structure}")
return [[path] for path in folder_structure]
# Process key, value as key for folder names and value its subfolders
for key, value in folder_structure.items():
if not value:
output.append(key)
@ -99,7 +112,7 @@ def _list_path_items(folder_structure):
return output
def get_project_basic_paths(project_name):
def get_project_basic_paths(project_name: str):
project_settings = get_project_settings(project_name)
folder_structure = (
project_settings["core"]["project_folder_structure"]

View file

@ -1,3 +1,5 @@
"""Library functions for publishing."""
from __future__ import annotations
import os
import sys
import inspect
@ -12,8 +14,8 @@ import pyblish.plugin
import pyblish.api
from ayon_core.lib import (
Logger,
import_filepath,
Logger,
filter_profiles,
)
from ayon_core.settings import get_project_settings
@ -163,7 +165,7 @@ class HelpContent:
def load_help_content_from_filepath(filepath):
"""Load help content from xml file.
Xml file may containt errors and warnings.
Xml file may contain errors and warnings.
"""
errors = {}
warnings = {}
@ -208,8 +210,9 @@ def load_help_content_from_plugin(plugin):
return load_help_content_from_filepath(filepath)
def publish_plugins_discover(paths=None):
"""Find and return available pyblish plug-ins
def publish_plugins_discover(
paths: Optional[list[str]] = None) -> DiscoverResult:
"""Find and return available pyblish plug-ins.
Overridden function from `pyblish` module to be able to collect
crashed files and reason of their crash.
@ -252,17 +255,14 @@ def publish_plugins_discover(paths=None):
continue
try:
module = import_filepath(abspath, mod_name)
module = import_filepath(
abspath, mod_name, sys_module_name=mod_name)
# Store reference to original module, to avoid
# garbage collection from collecting it's global
# imports, such as `import os`.
sys.modules[abspath] = module
except Exception as err:
except Exception as err: # noqa: BLE001
# we need broad exception to catch all possible errors.
result.crashed_file_paths[abspath] = sys.exc_info()
log.debug("Skipped: \"%s\" (%s)", mod_name, err)
log.debug('Skipped: "%s" (%s)', mod_name, err)
continue
for plugin in pyblish.plugin.plugins_from_module(module):
@ -280,9 +280,8 @@ def publish_plugins_discover(paths=None):
continue
plugin_names.append(plugin.__name__)
plugin.__module__ = module.__file__
key = "{0}.{1}".format(plugin.__module__, plugin.__name__)
plugin.__file__ = module.__file__
key = f"{module.__file__}.{plugin.__name__}"
plugins[key] = plugin
# Include plug-ins from registration.
@ -361,7 +360,7 @@ def get_plugin_settings(plugin, project_settings, log, category=None):
# Settings category determined from path
# - usually path is './<category>/plugins/publish/<plugin file>'
# - category can be host name of addon name ('maya', 'deadline', ...)
filepath = os.path.normpath(inspect.getsourcefile(plugin))
filepath = os.path.normpath(inspect.getfile(plugin))
split_path = filepath.rsplit(os.path.sep, 5)
if len(split_path) < 4:
@ -427,7 +426,7 @@ def filter_pyblish_plugins(plugins):
log = Logger.get_logger("filter_pyblish_plugins")
# TODO: Don't use host from 'pyblish.api' but from defined host by us.
# - kept becau on farm is probably used host 'shell' which propably
# - kept because on farm is probably used host 'shell' which probably
# affect how settings are applied there
host_name = pyblish.api.current_host()
project_name = os.environ.get("AYON_PROJECT_NAME")
@ -464,6 +463,12 @@ def filter_pyblish_plugins(plugins):
if getattr(plugin, "enabled", True) is False:
plugins.remove(plugin)
# Pyblish already operated a filter based on host.
# But applying settings might have changed "hosts"
# value in plugin so re-filter.
elif not pyblish.plugin.host_is_compatible(plugin):
plugins.remove(plugin)
def get_errored_instances_from_context(context, plugin=None):
"""Collect failed instances from pyblish context.
@ -523,7 +528,7 @@ def filter_instances_for_context_plugin(plugin, context):
Args:
plugin (pyblish.api.Plugin): Plugin with filters.
context (pyblish.api.Context): Pyblish context with insances.
context (pyblish.api.Context): Pyblish context with instances.
Returns:
Iterator[pyblish.lib.Instance]: Iteration of valid instances.

View file

@ -292,6 +292,9 @@ class OptionalPyblishPluginMixin(AYONPyblishPluginMixin):
```
"""
# Allow exposing tooltip from class with `optional_tooltip` attribute
optional_tooltip: Optional[str] = None
@classmethod
def get_attribute_defs(cls):
"""Attribute definitions based on plugin's optional attribute."""
@ -304,8 +307,14 @@ class OptionalPyblishPluginMixin(AYONPyblishPluginMixin):
active = getattr(cls, "active", True)
# Return boolean stored under 'active' key with label of the class name
label = cls.label or cls.__name__
return [
BoolDef("active", default=active, label=label)
BoolDef(
"active",
default=active,
label=label,
tooltip=cls.optional_tooltip,
)
]
def is_active(self, data):

View file

@ -54,6 +54,7 @@ from ayon_core.pipeline.plugin_discover import (
from ayon_core.pipeline.create import (
discover_legacy_creator_plugins,
CreateContext,
HiddenCreator,
)
_NOT_SET = object()
@ -309,7 +310,13 @@ class AbstractTemplateBuilder(ABC):
self._creators_by_name = creators_by_name
def _collect_creators(self):
self._creators_by_name = dict(self.create_context.creators)
self._creators_by_name = {
identifier: creator
for identifier, creator
in self.create_context.manual_creators.items()
# Do not list HiddenCreator even though it is a 'manual creator'
if not isinstance(creator, HiddenCreator)
}
def get_creators_by_name(self):
if self._creators_by_name is None:

View file

@ -116,11 +116,11 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin):
if not_found_folder_paths:
joined_folder_paths = ", ".join(
["\"{}\"".format(path) for path in not_found_folder_paths]
[f"\"{path}\"" for path in not_found_folder_paths]
)
self.log.warning(
f"Not found folder entities with paths {joined_folder_paths}."
)
self.log.warning((
"Not found folder entities with paths \"{}\"."
).format(joined_folder_paths))
def fill_missing_task_entities(self, context, project_name):
self.log.debug("Querying task entities for instances.")

View file

@ -39,6 +39,7 @@ class CollectAudio(pyblish.api.ContextPlugin):
"blender",
"houdini",
"max",
"circuit",
]
audio_product_name = "audioMain"

View file

@ -32,10 +32,12 @@ class CollectCoreJobEnvVars(pyblish.api.ContextPlugin):
for key in [
"AYON_BUNDLE_NAME",
"AYON_DEFAULT_SETTINGS_VARIANT",
"AYON_USE_STAGING",
"AYON_IN_TESTS",
# NOTE Not sure why workdir is needed?
"AYON_WORKDIR",
# DEPRECATED remove when deadline stops using it (added in 1.1.2)
"AYON_DEFAULT_SETTINGS_VARIANT",
]:
value = os.getenv(key)
if value:

View file

@ -1,78 +1,120 @@
"""Plugin for collecting OTIO frame ranges and related timing information.
This module contains a unified plugin that handles:
- Basic timeline frame ranges
- Source media frame ranges
- Retimed clip frame ranges
"""
Requires:
otioTimeline -> context data attribute
review -> instance data attribute
masterLayer -> instance data attribute
otioClipRange -> instance data attribute
"""
from pprint import pformat
import opentimelineio as otio
import pyblish.api
from ayon_core.pipeline.editorial import (
get_media_range_with_retimes,
otio_range_to_frame_range,
otio_range_with_handles,
)
class CollectOtioFrameRanges(pyblish.api.InstancePlugin):
"""Getting otio ranges from otio_clip
def validate_otio_clip(instance, logger):
"""Validate if instance has required OTIO clip data.
Adding timeline and source ranges to instance data"""
Args:
instance: The instance to validate
logger: Logger object to use for debug messages
label = "Collect OTIO Frame Ranges"
Returns:
bool: True if valid, False otherwise
"""
if not instance.data.get("otioClip"):
logger.debug("Skipping collect OTIO range - no clip found.")
return False
return True
class CollectOtioRanges(pyblish.api.InstancePlugin):
"""Collect all OTIO-related frame ranges and timing information.
This plugin handles collection of:
- Basic timeline frame ranges with handles
- Source media frame ranges with handles
- Retimed clip frame ranges
Requires:
otioClip (otio.schema.Clip): OTIO clip object
workfileFrameStart (int): Starting frame of work file
Optional:
shotDurationFromSource (int): Duration from source if retimed
Provides:
frameStart (int): Start frame in timeline
frameEnd (int): End frame in timeline
clipIn (int): Clip in point
clipOut (int): Clip out point
clipInH (int): Clip in point with handles
clipOutH (int): Clip out point with handles
sourceStart (int): Source media start frame
sourceEnd (int): Source media end frame
sourceStartH (int): Source media start frame with handles
sourceEndH (int): Source media end frame with handles
"""
label = "Collect OTIO Ranges"
order = pyblish.api.CollectorOrder - 0.08
families = ["shot", "clip"]
hosts = ["resolve", "hiero", "flame", "traypublisher"]
def process(self, instance):
# Not all hosts can import these modules.
import opentimelineio as otio
from ayon_core.pipeline.editorial import (
get_media_range_with_retimes,
otio_range_to_frame_range,
otio_range_with_handles
)
"""Process the instance to collect all frame ranges.
if not instance.data.get("otioClip"):
self.log.debug("Skipping collect OTIO frame range.")
Args:
instance: The instance to process
"""
if not validate_otio_clip(instance, self.log):
return
# get basic variables
otio_clip = instance.data["otioClip"]
# Collect timeline ranges if workfile start frame is available
if "workfileFrameStart" in instance.data:
self._collect_timeline_ranges(instance, otio_clip)
# Traypublisher Simple or Advanced editorial publishing is
# working with otio clips which are having no available range
# because they are not having any media references.
try:
otio_clip.available_range()
has_available_range = True
except otio._otio.CannotComputeAvailableRangeError:
self.log.info("Clip has no available range")
has_available_range = False
# Collect source ranges if clip has available range
if has_available_range:
self._collect_source_ranges(instance, otio_clip)
# Handle retimed ranges if source duration is available
if "shotDurationFromSource" in instance.data:
self._collect_retimed_ranges(instance, otio_clip)
def _collect_timeline_ranges(self, instance, otio_clip):
"""Collect basic timeline frame ranges."""
workfile_start = instance.data["workfileFrameStart"]
workfile_source_duration = instance.data.get("shotDurationFromSource")
# get ranges
# Get timeline ranges
otio_tl_range = otio_clip.range_in_parent()
otio_src_range = otio_clip.source_range
otio_avalable_range = otio_clip.available_range()
otio_tl_range_handles = otio_range_with_handles(
otio_tl_range, instance)
otio_src_range_handles = otio_range_with_handles(
otio_src_range, instance)
otio_tl_range,
instance
)
# get source avalable start frame
src_starting_from = otio.opentime.to_frames(
otio_avalable_range.start_time,
otio_avalable_range.start_time.rate)
# Convert to frames
tl_start, tl_end = otio_range_to_frame_range(otio_tl_range)
tl_start_h, tl_end_h = otio_range_to_frame_range(otio_tl_range_handles)
# convert to frames
range_convert = otio_range_to_frame_range
tl_start, tl_end = range_convert(otio_tl_range)
tl_start_h, tl_end_h = range_convert(otio_tl_range_handles)
src_start, src_end = range_convert(otio_src_range)
src_start_h, src_end_h = range_convert(otio_src_range_handles)
frame_start = workfile_start
frame_end = frame_start + otio.opentime.to_frames(
otio_tl_range.duration, otio_tl_range.duration.rate) - 1
# in case of retimed clip and frame range should not be retimed
if workfile_source_duration:
# get available range trimmed with processed retimes
retimed_attributes = get_media_range_with_retimes(
otio_clip, 0, 0)
self.log.debug(
">> retimed_attributes: {}".format(retimed_attributes))
media_in = int(retimed_attributes["mediaIn"])
media_out = int(retimed_attributes["mediaOut"])
frame_end = frame_start + (media_out - media_in) + 1
self.log.debug(frame_end)
frame_end = frame_start + otio_tl_range.duration.to_frames() - 1
data = {
"frameStart": frame_start,
@ -81,13 +123,77 @@ class CollectOtioFrameRanges(pyblish.api.InstancePlugin):
"clipOut": tl_end - 1,
"clipInH": tl_start_h,
"clipOutH": tl_end_h - 1,
"sourceStart": src_starting_from + src_start,
"sourceEnd": src_starting_from + src_end - 1,
"sourceStartH": src_starting_from + src_start_h,
"sourceEndH": src_starting_from + src_end_h - 1,
}
instance.data.update(data)
self.log.debug(
"_ data: {}".format(pformat(data)))
self.log.debug(
"_ instance.data: {}".format(pformat(instance.data)))
self.log.debug(f"Added frame ranges: {pformat(data)}")
def _collect_source_ranges(self, instance, otio_clip):
"""Collect source media frame ranges."""
# Get source ranges
otio_src_range = otio_clip.source_range
otio_available_range = otio_clip.available_range()
# Backward-compatibility for Hiero OTIO exporter.
# NTSC compatibility might introduce floating rates, when these are
# not exactly the same (23.976 vs 23.976024627685547)
# this will cause precision issue in computation.
# Currently round to 2 decimals for comparison,
# but this should always rescale after that.
rounded_av_rate = round(otio_available_range.start_time.rate, 2)
rounded_src_rate = round(otio_src_range.start_time.rate, 2)
if rounded_av_rate != rounded_src_rate:
conformed_src_in = otio_src_range.start_time.rescaled_to(
otio_available_range.start_time.rate
)
conformed_src_duration = otio_src_range.duration.rescaled_to(
otio_available_range.duration.rate
)
conformed_source_range = otio.opentime.TimeRange(
start_time=conformed_src_in,
duration=conformed_src_duration
)
else:
conformed_source_range = otio_src_range
source_start = conformed_source_range.start_time
source_end = source_start + conformed_source_range.duration
handle_start = otio.opentime.RationalTime(
instance.data.get("handleStart", 0),
source_start.rate
)
handle_end = otio.opentime.RationalTime(
instance.data.get("handleEnd", 0),
source_start.rate
)
source_start_h = source_start - handle_start
source_end_h = source_end + handle_end
data = {
"sourceStart": source_start.to_frames(),
"sourceEnd": source_end.to_frames() - 1,
"sourceStartH": source_start_h.to_frames(),
"sourceEndH": source_end_h.to_frames() - 1,
}
instance.data.update(data)
self.log.debug(f"Added source ranges: {pformat(data)}")
def _collect_retimed_ranges(self, instance, otio_clip):
"""Handle retimed clip frame ranges."""
retimed_attributes = get_media_range_with_retimes(otio_clip, 0, 0)
self.log.debug(f"Retimed attributes: {retimed_attributes}")
frame_start = instance.data["frameStart"]
media_in = int(retimed_attributes["mediaIn"])
media_out = int(retimed_attributes["mediaOut"])
frame_end = frame_start + (media_out - media_in)
data = {
"frameStart": frame_start,
"frameEnd": frame_end,
"sourceStart": media_in,
"sourceEnd": media_out,
"sourceStartH": media_in - int(retimed_attributes["handleStart"]),
"sourceEndH": media_out + int(retimed_attributes["handleEnd"]),
}
instance.data.update(data)
self.log.debug(f"Updated retimed values: {data}")

View file

@ -36,6 +36,16 @@ class CollectOtioReview(pyblish.api.InstancePlugin):
# optionally get `reviewTrack`
review_track_name = instance.data.get("reviewTrack")
# [clip_media] setting:
# Extract current clip source range as reviewable.
# Flag review content from otio_clip.
if not review_track_name and "review" in instance.data["families"]:
otio_review_clips = [otio_clip]
# skip if no review track available
elif not review_track_name:
return
# generate range in parent
otio_tl_range = otio_clip.range_in_parent()
@ -43,12 +53,14 @@ class CollectOtioReview(pyblish.api.InstancePlugin):
clip_frame_end = int(
otio_tl_range.start_time.value + otio_tl_range.duration.value)
# skip if no review track available
if not review_track_name:
return
# loop all tracks and match with name in `reviewTrack`
for track in otio_timeline.tracks:
# No review track defined, skip the loop
if review_track_name is None:
break
# Not current review track, skip it.
if review_track_name != track.name:
continue

View file

@ -6,6 +6,7 @@ Provides:
instance -> otioReviewClips
"""
import os
import math
import clique
import pyblish.api
@ -69,9 +70,17 @@ class CollectOtioSubsetResources(
self.log.debug(
">> retimed_attributes: {}".format(retimed_attributes))
# break down into variables
media_in = int(retimed_attributes["mediaIn"])
media_out = int(retimed_attributes["mediaOut"])
# break down into variables as rounded frame numbers
#
# 0 1 2 3 4
# |-------------|---------------|--------------|-------------|
# |_______________media range_______________|
# 0.6 3.2
#
# As rounded frames, media_in = 0 and media_out = 4
media_in = math.floor(retimed_attributes["mediaIn"])
media_out = math.ceil(retimed_attributes["mediaOut"])
handle_start = int(retimed_attributes["handleStart"])
handle_end = int(retimed_attributes["handleEnd"])
@ -149,7 +158,6 @@ class CollectOtioSubsetResources(
self.log.info(
"frame_start-frame_end: {}-{}".format(frame_start, frame_end))
review_repre = None
if is_sequence:
# file sequence way
@ -174,17 +182,18 @@ class CollectOtioSubsetResources(
path, trimmed_media_range_h, metadata)
self.staging_dir, collection = collection_data
self.log.debug(collection)
repre = self._create_representation(
frame_start, frame_end, collection=collection)
if len(collection.indexes) > 1:
self.log.debug(collection)
repre = self._create_representation(
frame_start, frame_end, collection=collection)
else:
filename = tuple(collection)[0]
self.log.debug(filename)
# TODO: discuss this, it erases frame number.
repre = self._create_representation(
frame_start, frame_end, file=filename)
if (
not instance.data.get("otioReviewClips")
and "review" in instance.data["families"]
):
review_repre = self._create_representation(
frame_start, frame_end, collection=collection,
delete=True, review=True)
else:
_trim = False
@ -200,13 +209,6 @@ class CollectOtioSubsetResources(
repre = self._create_representation(
frame_start, frame_end, file=filename, trim=_trim)
if (
not instance.data.get("otioReviewClips")
and "review" in instance.data["families"]
):
review_repre = self._create_representation(
frame_start, frame_end,
file=filename, delete=True, review=True)
instance.data["originalDirname"] = self.staging_dir
@ -219,9 +221,6 @@ class CollectOtioSubsetResources(
instance.data["representations"].append(repre)
# add review representation to instance data
if review_repre:
instance.data["representations"].append(review_repre)
self.log.debug(instance.data)

View file

@ -14,23 +14,7 @@ class CollectSceneVersion(pyblish.api.ContextPlugin):
order = pyblish.api.CollectorOrder
label = 'Collect Scene Version'
# configurable in Settings
hosts = [
"aftereffects",
"blender",
"celaction",
"fusion",
"harmony",
"hiero",
"houdini",
"maya",
"max",
"nuke",
"photoshop",
"resolve",
"tvpaint",
"motionbuilder",
"substancepainter"
]
hosts = ["*"]
# in some cases of headless publishing (for example webpublisher using PS)
# you want to ignore version from name and let integrate use next version

View file

@ -54,7 +54,8 @@ class ExtractBurnin(publish.Extractor):
"houdini",
"max",
"blender",
"unreal"
"unreal",
"circuit",
]
optional = True

View file

@ -280,6 +280,9 @@ class ExtractOIIOTranscode(publish.Extractor):
collection = collections[0]
frames = list(collection.indexes)
if collection.holes():
return files_to_convert
frame_str = "{}-{}#".format(frames[0], frames[-1])
file_name = "{}{}{}".format(collection.head, frame_str,
collection.tail)

View file

@ -147,7 +147,6 @@ class ExtractOTIOReview(
self.actual_fps = available_range.duration.rate
start = src_range.start_time.rescaled_to(self.actual_fps)
duration = src_range.duration.rescaled_to(self.actual_fps)
src_frame_start = src_range.start_time.to_frames()
# Temporary.
# Some AYON custom OTIO exporter were implemented with
@ -157,7 +156,7 @@ class ExtractOTIOReview(
if (
is_clip_from_media_sequence(r_otio_cl)
and available_range_start_frame == media_ref.start_frame
and src_frame_start < media_ref.start_frame
and start.to_frames() < media_ref.start_frame
):
available_range = otio.opentime.TimeRange(
otio.opentime.RationalTime(0, rate=self.actual_fps),
@ -287,7 +286,7 @@ class ExtractOTIOReview(
)
instance.data["representations"].append(representation)
self.log.info("Adding representation: {}".format(representation))
self.log.debug("Adding representation: {}".format(representation))
def _create_representation(self, start, duration):
"""
@ -321,6 +320,9 @@ class ExtractOTIOReview(
end = max(collection.indexes)
files = [f for f in collection]
# single frame sequence
if len(files) == 1:
files = files[0]
ext = collection.format("{tail}")
representation_data.update({
"name": ext[1:],

View file

@ -91,7 +91,8 @@ class ExtractReview(pyblish.api.InstancePlugin):
"webpublisher",
"aftereffects",
"flame",
"unreal"
"unreal",
"circuit",
]
# Supported extensions
@ -196,7 +197,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
).format(repre_name))
continue
input_ext = repre["ext"]
input_ext = repre["ext"].lower()
if input_ext.startswith("."):
input_ext = input_ext[1:]

View file

@ -35,10 +35,12 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
"resolve",
"traypublisher",
"substancepainter",
"substancedesigner",
"nuke",
"aftereffects",
"unreal",
"houdini"
"houdini",
"circuit",
]
enabled = False
@ -342,8 +344,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
# to be published locally
continue
valid = "review" in tags or "thumb-nuke" in tags
if not valid:
if "review" not in tags:
continue
if not repre.get("files"):

View file

@ -1,7 +1,7 @@
from operator import attrgetter
import dataclasses
import os
from typing import Dict
from typing import Any, Dict, List
import pyblish.api
try:
@ -14,7 +14,8 @@ from ayon_core.lib import (
BoolDef,
UISeparatorDef,
UILabelDef,
EnumDef
EnumDef,
filter_profiles
)
try:
from ayon_core.pipeline.usdlib import (
@ -281,6 +282,9 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
"fx": 500,
"lighting": 600,
}
# Default profiles to set certain instance attribute defaults based on
# profiles in settings
profiles: List[Dict[str, Any]] = []
@classmethod
def apply_settings(cls, project_settings):
@ -298,6 +302,8 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
if contribution_layers:
cls.contribution_layers = contribution_layers
cls.profiles = plugin_settings.get("profiles", [])
def process(self, instance):
attr_values = self.get_attr_values_from_data(instance.data)
@ -463,6 +469,29 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
if not cls.instance_matches_plugin_families(instance):
return []
# Set default target layer based on product type
current_context_task_type = create_context.get_current_task_type()
profile = filter_profiles(cls.profiles, {
"product_types": instance.data["productType"],
"task_types": current_context_task_type
})
if not profile:
profile = {}
# Define defaults
default_enabled = profile.get("contribution_enabled", True)
default_contribution_layer = profile.get(
"contribution_layer", None)
default_apply_as_variant = profile.get(
"contribution_apply_as_variant", False)
default_target_product = profile.get(
"contribution_target_product", "usdAsset")
default_init_as = (
"asset"
if profile.get("contribution_target_product") == "usdAsset"
else "shot")
init_as_visible = False
# Attributes logic
publish_attributes = instance["publish_attributes"].get(
cls.__name__, {})
@ -485,7 +514,7 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
"In both cases the USD data itself is free to have "
"references and sublayers of its own."
),
default=True),
default=default_enabled),
TextDef("contribution_target_product",
label="Target product",
tooltip=(
@ -495,7 +524,7 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
"the contribution itself will be added to the "
"department layer."
),
default="usdAsset",
default=default_target_product,
visible=visible),
EnumDef("contribution_target_product_init",
label="Initialize as",
@ -507,8 +536,8 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
"setting will do nothing."
),
items=["asset", "shot"],
default="asset",
visible=visible),
default=default_init_as,
visible=visible and init_as_visible),
# Asset layer, e.g. model.usd, look.usd, rig.usd
EnumDef("contribution_layer",
@ -520,7 +549,7 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
"the list) will contribute as a stronger opinion."
),
items=list(cls.contribution_layers.keys()),
default="model",
default=default_contribution_layer,
visible=visible),
BoolDef("contribution_apply_as_variant",
label="Add as variant",
@ -532,7 +561,7 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
"appended to as a sublayer to the department layer "
"instead."
),
default=True,
default=default_apply_as_variant,
visible=visible),
TextDef("contribution_variant_set_name",
label="Variant Set Name",
@ -588,31 +617,6 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
instance.set_publish_plugin_attr_defs(cls.__name__, new_attrs)
class CollectUSDLayerContributionsHoudiniLook(CollectUSDLayerContributions):
"""
This is solely here to expose the attribute definitions for the
Houdini "look" family.
"""
# TODO: Improve how this is built for the look family
hosts = ["houdini"]
families = ["look"]
label = CollectUSDLayerContributions.label + " (Look)"
@classmethod
def get_attr_defs_for_instance(cls, create_context, instance):
# Filtering of instance, if needed, can be customized
if not cls.instance_matches_plugin_families(instance):
return []
defs = super().get_attr_defs_for_instance(create_context, instance)
# Update default for department layer to look
layer_def = next(d for d in defs if d.key == "contribution_layer")
layer_def.default = "look"
return defs
class ValidateUSDDependencies(pyblish.api.InstancePlugin):
families = ["usdLayer"]

View file

@ -706,7 +706,8 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
# In case source are published in place we need to
# skip renumbering
repre_frame_start = repre.get("frameStart")
if repre_frame_start is not None:
explicit_frames = instance.data.get("hasExplicitFrames", False)
if not explicit_frames and repre_frame_start is not None:
index_frame_start = int(repre_frame_start)
# Shift destination sequence to the start frame
destination_indexes = [

View file

@ -0,0 +1,138 @@
import copy
import pyblish.api
from typing import List
from ayon_core.lib import EnumDef
from ayon_core.pipeline import OptionalPyblishPluginMixin
class AttachReviewables(
pyblish.api.InstancePlugin, OptionalPyblishPluginMixin
):
"""Attach reviewable to other instances
This pre-integrator plugin allows instances to be 'attached to' other
instances by moving all its representations over to the other instance.
Even though this technically could work for any representation the current
intent is to use for reviewables only, like e.g. `review` or `render`
product type.
When the reviewable is attached to another instance, the instance itself
will not be published as a separate entity. Instead, the representations
will be copied/moved to the instances it is attached to.
"""
families = ["render", "review"]
order = pyblish.api.IntegratorOrder - 0.499
label = "Attach reviewables"
settings_category = "core"
def process(self, instance):
# TODO: Support farm.
# If instance is being submitted to the farm we should pass through
# the 'attached reviewables' metadata to the farm job
# TODO: Reviewable frame range and resolutions
# Because we are attaching the data to another instance, how do we
# correctly propagate the resolution + frame rate to the other
# instance? Do we even need to?
# TODO: If this were to attach 'renders' to another instance that would
# mean there wouldn't necessarily be a render publish separate as a
# result. Is that correct expected behavior?
attr_values = self.get_attr_values_from_data(instance.data)
attach_to = attr_values.get("attach", [])
if not attach_to:
self.log.debug(
"Reviewable is not set to attach to another instance."
)
return
attach_instances: List[pyblish.api.Instance] = []
for attach_instance_id in attach_to:
# Find the `pyblish.api.Instance` matching the `CreatedInstance.id`
# in the `attach_to` list
attach_instance = next(
(
_inst
for _inst in instance.context
if _inst.data.get("instance_id") == attach_instance_id
),
None,
)
if attach_instance is None:
continue
# Skip inactive instances
if not attach_instance.data.get("active", True):
continue
# For now do not support attaching to 'farm' instances until we
# can pass the 'attaching' on to the farm jobs.
if attach_instance.data.get("farm"):
self.log.warning(
"Attaching to farm instances is not supported yet."
)
continue
attach_instances.append(attach_instance)
instances_names = ", ".join(
instance.name for instance in attach_instances
)
self.log.info(
f"Attaching reviewable to other instances: {instances_names}"
)
# Copy the representations of this reviewable instance to the other
# instance
representations = instance.data.get("representations", [])
for attach_instance in attach_instances:
self.log.info(f"Attaching to {attach_instance.name}")
attach_instance.data.setdefault("representations", []).extend(
copy.deepcopy(representations)
)
# Delete representations on the reviewable instance itself
for repre in representations:
self.log.debug(
"Marking representation as deleted because it was "
f"attached to other instances instead: {repre}"
)
repre.setdefault("tags", []).append("delete")
# Stop integrator from trying to integrate this instance
if attach_to:
instance.data["integrate"] = False
@classmethod
def get_attr_defs_for_instance(cls, create_context, instance):
# TODO: Check if instance is actually a 'reviewable'
# Filtering of instance, if needed, can be customized
if not cls.instance_matches_plugin_families(instance):
return []
items = []
for other_instance in create_context.instances:
if other_instance == instance:
continue
# Do not allow attaching to other reviewable instances
if other_instance.data["productType"] in cls.families:
continue
items.append(
{
"label": other_instance.label,
"value": str(other_instance.id),
}
)
return [
EnumDef(
"attach",
label="Attach reviewable",
multiselection=True,
items=items,
tooltip="Attach this reviewable to another instance",
)
]

View file

@ -2,7 +2,7 @@ import copy
import typing
from typing import Optional
from qtpy import QtWidgets, QtCore
from qtpy import QtWidgets, QtCore, QtGui
from ayon_core.lib.attribute_definitions import (
AbstractAttrDef,
@ -22,6 +22,8 @@ from ayon_core.tools.utils import (
FocusSpinBox,
FocusDoubleSpinBox,
MultiSelectionComboBox,
PlaceholderLineEdit,
PlaceholderPlainTextEdit,
set_style_property,
)
from ayon_core.tools.utils import NiceCheckbox
@ -502,9 +504,9 @@ class TextAttrWidget(_BaseAttrDefWidget):
self.multiline = self.attr_def.multiline
if self.multiline:
input_widget = QtWidgets.QPlainTextEdit(self)
input_widget = PlaceholderPlainTextEdit(self)
else:
input_widget = QtWidgets.QLineEdit(self)
input_widget = PlaceholderLineEdit(self)
# Override context menu event to add revert to default action
input_widget.contextMenuEvent = self._input_widget_context_event
@ -641,7 +643,9 @@ class EnumAttrWidget(_BaseAttrDefWidget):
def _ui_init(self):
if self.multiselection:
input_widget = MultiSelectionComboBox(self)
input_widget = MultiSelectionComboBox(
self, placeholder=self.attr_def.placeholder
)
else:
input_widget = CustomTextComboBox(self)
@ -655,6 +659,9 @@ class EnumAttrWidget(_BaseAttrDefWidget):
for item in self.attr_def.items:
input_widget.addItem(item["label"], item["value"])
if not self.attr_def.items:
self._add_empty_item(input_widget)
idx = input_widget.findData(self.attr_def.default)
if idx >= 0:
input_widget.setCurrentIndex(idx)
@ -671,6 +678,20 @@ class EnumAttrWidget(_BaseAttrDefWidget):
input_widget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
input_widget.customContextMenuRequested.connect(self._on_context_menu)
def _add_empty_item(self, input_widget):
model = input_widget.model()
if not isinstance(model, QtGui.QStandardItemModel):
return
root_item = model.invisibleRootItem()
empty_item = QtGui.QStandardItem()
empty_item.setData("< No items to select >", QtCore.Qt.DisplayRole)
empty_item.setData("", QtCore.Qt.UserRole)
empty_item.setFlags(QtCore.Qt.NoItemFlags)
root_item.appendRow(empty_item)
def _on_context_menu(self, pos):
menu = QtWidgets.QMenu(self)

View file

@ -1,12 +1,18 @@
from __future__ import annotations
import time
import collections
import contextlib
import typing
from abc import ABC, abstractmethod
import ayon_api
from ayon_core.lib import NestedCacheItem
if typing.TYPE_CHECKING:
from typing import Union
HIERARCHY_MODEL_SENDER = "hierarchy.model"
@ -82,19 +88,26 @@ class TaskItem:
Args:
task_id (str): Task id.
name (str): Name of task.
name (Union[str, None]): Task label.
task_type (str): Type of task.
parent_id (str): Parent folder id.
"""
def __init__(
self, task_id, name, task_type, parent_id
self,
task_id: str,
name: str,
label: Union[str, None],
task_type: str,
parent_id: str,
):
self.task_id = task_id
self.name = name
self.label = label
self.task_type = task_type
self.parent_id = parent_id
self._label = None
self._full_label = None
@property
def id(self):
@ -107,16 +120,17 @@ class TaskItem:
return self.task_id
@property
def label(self):
def full_label(self):
"""Label of task item for UI.
Returns:
str: Label of task item.
"""
if self._label is None:
self._label = "{} ({})".format(self.name, self.task_type)
return self._label
if self._full_label is None:
label = self.label or self.name
self._full_label = f"{label} ({self.task_type})"
return self._full_label
def to_data(self):
"""Converts task item to data.
@ -128,6 +142,7 @@ class TaskItem:
return {
"task_id": self.task_id,
"name": self.name,
"label": self.label,
"parent_id": self.parent_id,
"task_type": self.task_type,
}
@ -159,6 +174,7 @@ def _get_task_items_from_tasks(tasks):
output.append(TaskItem(
task["id"],
task["name"],
task["label"],
task["type"],
folder_id
))
@ -211,6 +227,9 @@ class HierarchyModel(object):
self._tasks_by_id = NestedCacheItem(
levels=2, default_factory=dict, lifetime=self.lifetime)
self._entity_ids_by_assignee = NestedCacheItem(
levels=2, default_factory=dict, lifetime=self.lifetime)
self._folders_refreshing = set()
self._tasks_refreshing = set()
self._controller = controller
@ -222,6 +241,8 @@ class HierarchyModel(object):
self._task_items.reset()
self._tasks_by_id.reset()
self._entity_ids_by_assignee.reset()
def refresh_project(self, project_name):
"""Force to refresh folder items for a project.
@ -368,7 +389,7 @@ class HierarchyModel(object):
sender (Union[str, None]): Who requested the task item.
Returns:
Union[TaskItem, None]: Task item found by name and folder id.
Optional[TaskItem]: Task item found by name and folder id.
"""
for task_item in self.get_task_items(project_name, folder_id, sender):
@ -445,6 +466,54 @@ class HierarchyModel(object):
output = self.get_task_entities(project_name, {task_id})
return output[task_id]
def get_entity_ids_for_assignees(
self, project_name: str, assignees: list[str]
):
folder_ids = set()
task_ids = set()
output = {
"folder_ids": folder_ids,
"task_ids": task_ids,
}
assignees = set(assignees)
for assignee in tuple(assignees):
cache = self._entity_ids_by_assignee[project_name][assignee]
if cache.is_valid:
assignees.discard(assignee)
assignee_data = cache.get_data()
folder_ids.update(assignee_data["folder_ids"])
task_ids.update(assignee_data["task_ids"])
if not assignees:
return output
tasks = ayon_api.get_tasks(
project_name,
assignees_all=assignees,
fields={"id", "folderId", "assignees"},
)
tasks_assignee = {}
for task in tasks:
folder_ids.add(task["folderId"])
task_ids.add(task["id"])
for assignee in task["assignees"]:
tasks_assignee.setdefault(assignee, []).append(task)
for assignee, tasks in tasks_assignee.items():
cache = self._entity_ids_by_assignee[project_name][assignee]
assignee_folder_ids = set()
assignee_task_ids = set()
assignee_data = {
"folder_ids": assignee_folder_ids,
"task_ids": assignee_task_ids,
}
for task in tasks:
assignee_folder_ids.add(task["folderId"])
assignee_task_ids.add(task["id"])
cache.update_data(assignee_data)
return output
@contextlib.contextmanager
def _folder_refresh_event_manager(self, project_name, sender):
self._folders_refreshing.add(project_name)

View file

@ -160,8 +160,8 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
Returns:
list[FolderItem]: Minimum possible information needed
for visualisation of folder hierarchy.
"""
"""
pass
@abstractmethod
@ -180,8 +180,8 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
Returns:
list[TaskItem]: Minimum possible information needed
for visualisation of tasks.
"""
"""
pass
@abstractmethod
@ -190,8 +190,8 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
Returns:
Union[str, None]: Selected project name.
"""
"""
pass
@abstractmethod
@ -200,8 +200,8 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
Returns:
Union[str, None]: Selected folder id.
"""
"""
pass
@abstractmethod
@ -210,8 +210,8 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
Returns:
Union[str, None]: Selected task id.
"""
"""
pass
@abstractmethod
@ -220,8 +220,8 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
Returns:
Union[str, None]: Selected task name.
"""
"""
pass
@abstractmethod
@ -238,8 +238,8 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
Returns:
dict[str, Union[str, None]]: Selected context.
"""
"""
pass
@abstractmethod
@ -249,8 +249,8 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
Args:
project_name (Union[str, None]): Project nameor None if no project
is selected.
"""
"""
pass
@abstractmethod
@ -260,8 +260,8 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
Args:
folder_id (Union[str, None]): Folder id or None if no folder
is selected.
"""
"""
pass
@abstractmethod
@ -273,8 +273,8 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
is selected.
task_name (Union[str, None]): Task name or None if no task
is selected.
"""
"""
pass
# Actions
@ -290,8 +290,8 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
Returns:
list[ActionItem]: List of action items that should be shown
for given context.
"""
"""
pass
@abstractmethod
@ -303,8 +303,8 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
folder_id (Union[str, None]): Folder id.
task_id (Union[str, None]): Task id.
action_id (str): Action identifier.
"""
"""
pass
@abstractmethod
@ -317,10 +317,10 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
project_name (Union[str, None]): Project name.
folder_id (Union[str, None]): Folder id.
task_id (Union[str, None]): Task id.
action_id (Iterable[str]): Action identifiers.
action_ids (Iterable[str]): Action identifiers.
enabled (bool): New value of force not open workfile.
"""
"""
pass
@abstractmethod
@ -340,5 +340,17 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
Triggers 'controller.refresh.actions.started' event at the beginning
and 'controller.refresh.actions.finished' at the end.
"""
pass
@abstractmethod
def get_my_tasks_entity_ids(self, project_name: str):
"""Get entity ids for my tasks.
Args:
project_name (str): Project name.
Returns:
dict[str, Union[list[str]]]: Folder and task ids.
"""
pass

View file

@ -1,4 +1,4 @@
from ayon_core.lib import Logger
from ayon_core.lib import Logger, get_ayon_username
from ayon_core.lib.events import QueuedEventSystem
from ayon_core.settings import get_project_settings
from ayon_core.tools.common_models import ProjectsModel, HierarchyModel
@ -6,6 +6,8 @@ from ayon_core.tools.common_models import ProjectsModel, HierarchyModel
from .abstract import AbstractLauncherFrontEnd, AbstractLauncherBackend
from .models import LauncherSelectionModel, ActionsModel
NOT_SET = object()
class BaseLauncherController(
AbstractLauncherFrontEnd, AbstractLauncherBackend
@ -15,6 +17,8 @@ class BaseLauncherController(
self._event_system = None
self._log = None
self._username = NOT_SET
self._selection_model = LauncherSelectionModel(self)
self._projects_model = ProjectsModel(self)
self._hierarchy_model = HierarchyModel(self)
@ -168,5 +172,19 @@ class BaseLauncherController(
self._emit_event("controller.refresh.actions.finished")
def get_my_tasks_entity_ids(self, project_name: str):
username = self._get_my_username()
assignees = []
if username:
assignees.append(username)
return self._hierarchy_model.get_entity_ids_for_assignees(
project_name, assignees
)
def _get_my_username(self):
if self._username is NOT_SET:
self._username = get_ayon_username()
return self._username
def _emit_event(self, topic, data=None):
self.emit_event(topic, data, "controller")

View file

@ -5,17 +5,17 @@ from ayon_core.tools.utils import (
PlaceholderLineEdit,
SquareButton,
RefreshButton,
)
from ayon_core.tools.utils import (
ProjectsCombobox,
FoldersWidget,
TasksWidget,
NiceCheckbox,
)
from ayon_core.tools.utils.lib import checkstate_int_to_enum
class HierarchyPage(QtWidgets.QWidget):
def __init__(self, controller, parent):
super(HierarchyPage, self).__init__(parent)
super().__init__(parent)
# Header
header_widget = QtWidgets.QWidget(self)
@ -43,23 +43,36 @@ class HierarchyPage(QtWidgets.QWidget):
)
content_body.setOrientation(QtCore.Qt.Horizontal)
# - Folders widget with filter
folders_wrapper = QtWidgets.QWidget(content_body)
# - filters
filters_widget = QtWidgets.QWidget(self)
folders_filter_text = PlaceholderLineEdit(folders_wrapper)
folders_filter_text = PlaceholderLineEdit(filters_widget)
folders_filter_text.setPlaceholderText("Filter folders...")
folders_widget = FoldersWidget(controller, folders_wrapper)
my_tasks_tooltip = (
"Filter folders and task to only those you are assigned to."
)
my_tasks_label = QtWidgets.QLabel("My tasks", filters_widget)
my_tasks_label.setToolTip(my_tasks_tooltip)
folders_wrapper_layout = QtWidgets.QVBoxLayout(folders_wrapper)
folders_wrapper_layout.setContentsMargins(0, 0, 0, 0)
folders_wrapper_layout.addWidget(folders_filter_text, 0)
folders_wrapper_layout.addWidget(folders_widget, 1)
my_tasks_checkbox = NiceCheckbox(filters_widget)
my_tasks_checkbox.setChecked(False)
my_tasks_checkbox.setToolTip(my_tasks_tooltip)
filters_layout = QtWidgets.QHBoxLayout(filters_widget)
filters_layout.setContentsMargins(0, 0, 0, 0)
filters_layout.addWidget(folders_filter_text, 1)
filters_layout.addWidget(my_tasks_label, 0)
filters_layout.addWidget(my_tasks_checkbox, 0)
# - Folders widget
folders_widget = FoldersWidget(controller, content_body)
folders_widget.set_header_visible(True)
# - Tasks widget
tasks_widget = TasksWidget(controller, content_body)
content_body.addWidget(folders_wrapper)
content_body.addWidget(folders_widget)
content_body.addWidget(tasks_widget)
content_body.setStretchFactor(0, 100)
content_body.setStretchFactor(1, 65)
@ -67,20 +80,27 @@ class HierarchyPage(QtWidgets.QWidget):
main_layout = QtWidgets.QVBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.addWidget(header_widget, 0)
main_layout.addWidget(filters_widget, 0)
main_layout.addWidget(content_body, 1)
btn_back.clicked.connect(self._on_back_clicked)
refresh_btn.clicked.connect(self._on_refresh_clicked)
folders_filter_text.textChanged.connect(self._on_filter_text_changed)
my_tasks_checkbox.stateChanged.connect(
self._on_my_tasks_checkbox_state_changed
)
self._is_visible = False
self._controller = controller
self._btn_back = btn_back
self._projects_combobox = projects_combobox
self._my_tasks_checkbox = my_tasks_checkbox
self._folders_widget = folders_widget
self._tasks_widget = tasks_widget
self._project_name = None
# Post init
projects_combobox.set_listen_to_selection_change(self._is_visible)
@ -91,10 +111,14 @@ class HierarchyPage(QtWidgets.QWidget):
self._projects_combobox.set_listen_to_selection_change(visible)
if visible and project_name:
self._projects_combobox.set_selection(project_name)
self._project_name = project_name
def refresh(self):
self._folders_widget.refresh()
self._tasks_widget.refresh()
self._on_my_tasks_checkbox_state_changed(
self._my_tasks_checkbox.checkState()
)
def _on_back_clicked(self):
self._controller.set_selected_project(None)
@ -104,3 +128,16 @@ class HierarchyPage(QtWidgets.QWidget):
def _on_filter_text_changed(self, text):
self._folders_widget.set_name_filter(text)
def _on_my_tasks_checkbox_state_changed(self, state):
folder_ids = None
task_ids = None
state = checkstate_int_to_enum(state)
if state == QtCore.Qt.Checked:
entity_ids = self._controller.get_my_tasks_entity_ids(
self._project_name
)
folder_ids = entity_ids["folder_ids"]
task_ids = entity_ids["task_ids"]
self._folders_widget.set_folder_ids_filter(folder_ids)
self._tasks_widget.set_task_ids_filter(task_ids)

View file

@ -17,7 +17,7 @@ class LauncherWindow(QtWidgets.QWidget):
page_side_anim_interval = 250
def __init__(self, controller=None, parent=None):
super(LauncherWindow, self).__init__(parent)
super().__init__(parent)
if controller is None:
controller = BaseLauncherController()
@ -153,14 +153,14 @@ class LauncherWindow(QtWidgets.QWidget):
self.resize(520, 740)
def showEvent(self, event):
super(LauncherWindow, self).showEvent(event)
super().showEvent(event)
self._window_is_active = True
if not self._actions_refresh_timer.isActive():
self._actions_refresh_timer.start()
self._controller.refresh()
def closeEvent(self, event):
super(LauncherWindow, self).closeEvent(event)
super().closeEvent(event)
self._window_is_active = False
self._actions_refresh_timer.stop()
@ -176,7 +176,7 @@ class LauncherWindow(QtWidgets.QWidget):
self._on_actions_refresh_timeout()
self._actions_refresh_timer.start()
super(LauncherWindow, self).changeEvent(event)
super().changeEvent(event)
def _on_actions_refresh_timeout(self):
# Stop timer if widget is not visible

View file

@ -108,6 +108,7 @@ class VersionItem:
version (int): Version. Can be negative when is hero version.
is_hero (bool): Is hero version.
product_id (str): Product id.
task_id (Union[str, None]): Task id.
thumbnail_id (Union[str, None]): Thumbnail id.
published_time (Union[str, None]): Published time in format
'%Y%m%dT%H%M%SZ'.
@ -127,6 +128,7 @@ class VersionItem:
version,
is_hero,
product_id,
task_id,
thumbnail_id,
published_time,
author,
@ -140,6 +142,7 @@ class VersionItem:
):
self.version_id = version_id
self.product_id = product_id
self.task_id = task_id
self.thumbnail_id = thumbnail_id
self.version = version
self.is_hero = is_hero
@ -161,6 +164,7 @@ class VersionItem:
and self.version == other.version
and self.version_id == other.version_id
and self.product_id == other.product_id
and self.task_id == other.task_id
)
def __ne__(self, other):
@ -198,6 +202,7 @@ class VersionItem:
return {
"version_id": self.version_id,
"product_id": self.product_id,
"task_id": self.task_id,
"thumbnail_id": self.thumbnail_id,
"version": self.version,
"is_hero": self.is_hero,
@ -536,6 +541,55 @@ class FrontendLoaderController(_BaseLoaderController):
"""
pass
@abstractmethod
def get_task_items(self, project_name, folder_ids, sender=None):
"""Task items for folder ids.
Args:
project_name (str): Project name.
folder_ids (Iterable[str]): Folder ids.
sender (Optional[str]): Sender who requested the items.
Returns:
list[TaskItem]: List of task items.
"""
pass
@abstractmethod
def get_task_type_items(self, project_name, sender=None):
"""Task type items for a project.
This function may trigger events with topics
'projects.task_types.refresh.started' and
'projects.task_types.refresh.finished' which will contain 'sender'
value in data.
That may help to avoid re-refresh of items in UI elements.
Args:
project_name (str): Project name.
sender (str): Who requested task type items.
Returns:
list[TaskTypeItem]: Task type information.
"""
pass
@abstractmethod
def get_folder_labels(self, project_name, folder_ids):
"""Get folder labels for folder ids.
Args:
project_name (str): Project name.
folder_ids (Iterable[str]): Folder ids.
Returns:
dict[str, Optional[str]]: Folder labels by folder id.
"""
pass
@abstractmethod
def get_project_status_items(self, project_name, sender=None):
"""Items for all projects available on server.
@ -717,8 +771,30 @@ class FrontendLoaderController(_BaseLoaderController):
Returns:
list[str]: Selected folder ids.
"""
"""
pass
@abstractmethod
def get_selected_task_ids(self):
"""Get selected task ids.
The information is based on last selection from UI.
Returns:
list[str]: Selected folder ids.
"""
pass
@abstractmethod
def set_selected_tasks(self, task_ids):
"""Set selected tasks.
Args:
task_ids (Iterable[str]): Selected task ids.
"""
pass
@abstractmethod
@ -729,8 +805,8 @@ class FrontendLoaderController(_BaseLoaderController):
Returns:
list[str]: Selected version ids.
"""
"""
pass
@abstractmethod

View file

@ -198,6 +198,31 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
def get_folder_items(self, project_name, sender=None):
return self._hierarchy_model.get_folder_items(project_name, sender)
def get_task_items(self, project_name, folder_ids, sender=None):
output = []
for folder_id in folder_ids:
output.extend(self._hierarchy_model.get_task_items(
project_name, folder_id, sender
))
return output
def get_task_type_items(self, project_name, sender=None):
return self._projects_model.get_task_type_items(
project_name, sender
)
def get_folder_labels(self, project_name, folder_ids):
folder_items_by_id = self._hierarchy_model.get_folder_items_by_id(
project_name, folder_ids
)
output = {}
for folder_id, folder_item in folder_items_by_id.items():
label = None
if folder_item is not None:
label = folder_item.label
output[folder_id] = label
return output
def get_product_items(self, project_name, folder_ids, sender=None):
return self._products_model.get_product_items(
project_name, folder_ids, sender)
@ -299,6 +324,12 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
def set_selected_folders(self, folder_ids):
self._selection_model.set_selected_folders(folder_ids)
def get_selected_task_ids(self):
return self._selection_model.get_selected_task_ids()
def set_selected_tasks(self, task_ids):
self._selection_model.set_selected_tasks(task_ids)
def get_selected_version_ids(self):
return self._selection_model.get_selected_version_ids()

View file

@ -55,6 +55,7 @@ def version_item_from_entity(version):
version=version_num,
is_hero=is_hero,
product_id=version["productId"],
task_id=version["taskId"],
thumbnail_id=version["thumbnailId"],
published_time=published_time,
author=author,

View file

@ -14,6 +14,7 @@ class SelectionModel(object):
self._project_name = None
self._folder_ids = set()
self._task_ids = set()
self._version_ids = set()
self._representation_ids = set()
@ -48,6 +49,23 @@ class SelectionModel(object):
self.event_source
)
def get_selected_task_ids(self):
return self._task_ids
def set_selected_tasks(self, task_ids):
if task_ids == self._task_ids:
return
self._task_ids = task_ids
self._controller.emit_event(
"selection.tasks.changed",
{
"project_name": self._project_name,
"task_ids": task_ids,
},
self.event_source
)
def get_selected_version_ids(self):
return self._version_ids

View file

@ -1,7 +1,10 @@
from __future__ import annotations
import typing
from typing import List, Tuple, Optional, Iterable, Any
from qtpy import QtWidgets, QtCore, QtGui
from ayon_core.tools.utils import get_qt_icon
from ayon_core.tools.utils.lib import (
checkstate_int_to_enum,
checkstate_enum_to_int,
@ -11,14 +14,269 @@ from ayon_core.tools.utils.constants import (
UNCHECKED_INT,
ITEM_IS_USER_TRISTATE,
)
if typing.TYPE_CHECKING:
from ayon_core.tools.loader.abstract import FrontendLoaderController
VALUE_ITEM_TYPE = 0
STANDARD_ITEM_TYPE = 1
SEPARATOR_ITEM_TYPE = 2
VALUE_ITEM_SUBTYPE = 0
SELECT_ALL_SUBTYPE = 1
DESELECT_ALL_SUBTYPE = 2
SWAP_STATE_SUBTYPE = 3
class BaseQtModel(QtGui.QStandardItemModel):
_empty_icon = None
def __init__(
self,
item_type_role: int,
item_subtype_role: int,
empty_values_label: str,
controller: FrontendLoaderController,
):
self._item_type_role = item_type_role
self._item_subtype_role = item_subtype_role
self._empty_values_label = empty_values_label
self._controller = controller
self._last_project = None
self._select_project_item = None
self._empty_values_item = None
self._select_all_item = None
self._deselect_all_item = None
self._swap_states_item = None
super().__init__()
self.refresh(None)
def _get_standard_items(self) -> list[QtGui.QStandardItem]:
raise NotImplementedError(
"'_get_standard_items' is not implemented"
f" for {self.__class__}"
)
def _clear_standard_items(self):
raise NotImplementedError(
"'_clear_standard_items' is not implemented"
f" for {self.__class__}"
)
def _prepare_new_value_items(
self, project_name: str, project_changed: bool
) -> tuple[
list[QtGui.QStandardItem], list[QtGui.QStandardItem]
]:
raise NotImplementedError(
"'_prepare_new_value_items' is not implemented"
f" for {self.__class__}"
)
def refresh(self, project_name: Optional[str]):
# New project was selected
project_changed = False
if project_name != self._last_project:
self._last_project = project_name
project_changed = True
if project_name is None:
self._add_select_project_item()
return
value_items, items_to_remove = self._prepare_new_value_items(
project_name, project_changed
)
if not value_items:
self._add_empty_values_item()
return
self._remove_empty_items()
root_item = self.invisibleRootItem()
for row_idx, value_item in enumerate(value_items):
if value_item.row() == row_idx:
continue
if value_item.row() >= 0:
root_item.takeRow(value_item.row())
root_item.insertRow(row_idx, value_item)
for item in items_to_remove:
root_item.removeRow(item.row())
self._add_selection_items()
def setData(self, index, value, role):
if role == QtCore.Qt.CheckStateRole and index.isValid():
item_subtype = index.data(self._item_subtype_role)
if item_subtype == SELECT_ALL_SUBTYPE:
for item in self._get_standard_items():
item.setCheckState(QtCore.Qt.Checked)
return True
if item_subtype == DESELECT_ALL_SUBTYPE:
for item in self._get_standard_items():
item.setCheckState(QtCore.Qt.Unchecked)
return True
if item_subtype == SWAP_STATE_SUBTYPE:
for item in self._get_standard_items():
current_state = item.checkState()
item.setCheckState(
QtCore.Qt.Checked
if current_state == QtCore.Qt.Unchecked
else QtCore.Qt.Unchecked
)
return True
return super().setData(index, value, role)
@classmethod
def _get_empty_icon(cls):
if cls._empty_icon is None:
pix = QtGui.QPixmap(1, 1)
pix.fill(QtCore.Qt.transparent)
cls._empty_icon = QtGui.QIcon(pix)
return cls._empty_icon
def _init_default_items(self):
if self._empty_values_item is not None:
return
empty_values_item = QtGui.QStandardItem(self._empty_values_label)
select_project_item = QtGui.QStandardItem("Select project...")
select_all_item = QtGui.QStandardItem("Select all")
deselect_all_item = QtGui.QStandardItem("Deselect all")
swap_states_item = QtGui.QStandardItem("Swap")
for item in (
empty_values_item,
select_project_item,
select_all_item,
deselect_all_item,
swap_states_item,
):
item.setData(STANDARD_ITEM_TYPE, self._item_type_role)
select_all_item.setIcon(get_qt_icon({
"type": "material-symbols",
"name": "done_all",
"color": "white"
}))
deselect_all_item.setIcon(get_qt_icon({
"type": "material-symbols",
"name": "remove_done",
"color": "white"
}))
swap_states_item.setIcon(get_qt_icon({
"type": "material-symbols",
"name": "swap_horiz",
"color": "white"
}))
for item in (
empty_values_item,
select_project_item,
):
item.setFlags(QtCore.Qt.NoItemFlags)
for item, item_type in (
(select_all_item, SELECT_ALL_SUBTYPE),
(deselect_all_item, DESELECT_ALL_SUBTYPE),
(swap_states_item, SWAP_STATE_SUBTYPE),
):
item.setData(item_type, self._item_subtype_role)
for item in (
select_all_item,
deselect_all_item,
swap_states_item,
):
item.setFlags(
QtCore.Qt.ItemIsEnabled
| QtCore.Qt.ItemIsSelectable
| QtCore.Qt.ItemIsUserCheckable
)
self._empty_values_item = empty_values_item
self._select_project_item = select_project_item
self._select_all_item = select_all_item
self._deselect_all_item = deselect_all_item
self._swap_states_item = swap_states_item
def _get_empty_values_item(self):
self._init_default_items()
return self._empty_values_item
def _get_select_project_item(self):
self._init_default_items()
return self._select_project_item
def _get_empty_items(self):
self._init_default_items()
return [
self._empty_values_item,
self._select_project_item,
]
def _get_selection_items(self):
self._init_default_items()
return [
self._select_all_item,
self._deselect_all_item,
self._swap_states_item,
]
def _get_default_items(self):
return self._get_empty_items() + self._get_selection_items()
def _add_select_project_item(self):
item = self._get_select_project_item()
if item.row() < 0:
self._remove_items()
root_item = self.invisibleRootItem()
root_item.appendRow(item)
def _add_empty_values_item(self):
item = self._get_empty_values_item()
if item.row() < 0:
self._remove_items()
root_item = self.invisibleRootItem()
root_item.appendRow(item)
def _add_selection_items(self):
root_item = self.invisibleRootItem()
items = self._get_selection_items()
for item in self._get_selection_items():
row = item.row()
if row >= 0:
root_item.takeRow(row)
root_item.appendRows(items)
def _remove_items(self):
root_item = self.invisibleRootItem()
for item in self._get_default_items():
if item.row() < 0:
continue
root_item.takeRow(item.row())
root_item.removeRows(0, root_item.rowCount())
self._clear_standard_items()
def _remove_empty_items(self):
root_item = self.invisibleRootItem()
for item in self._get_empty_items():
if item.row() < 0:
continue
root_item.takeRow(item.row())
class CustomPaintDelegate(QtWidgets.QStyledItemDelegate):
"""Delegate showing status name and short name."""
_empty_icon = None
_checked_value = checkstate_enum_to_int(QtCore.Qt.Checked)
_checked_bg_color = QtGui.QColor("#2C3B4C")
@ -38,6 +296,14 @@ class CustomPaintDelegate(QtWidgets.QStyledItemDelegate):
self._icon_role = icon_role
self._item_type_role = item_type_role
@classmethod
def _get_empty_icon(cls):
if cls._empty_icon is None:
pix = QtGui.QPixmap(1, 1)
pix.fill(QtCore.Qt.transparent)
cls._empty_icon = QtGui.QIcon(pix)
return cls._empty_icon
def paint(self, painter, option, index):
item_type = None
if self._item_type_role is not None:
@ -70,6 +336,9 @@ class CustomPaintDelegate(QtWidgets.QStyledItemDelegate):
if option.state & QtWidgets.QStyle.State_Open:
state = QtGui.QIcon.On
icon = self._get_index_icon(index)
if icon is None or icon.isNull():
icon = self._get_empty_icon()
option.features |= QtWidgets.QStyleOptionViewItem.HasDecoration
# Disable visible check indicator
@ -241,6 +510,10 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox):
QtCore.Qt.Key_Home,
QtCore.Qt.Key_End,
}
_top_bottom_margins = 1
_top_bottom_padding = 2
_left_right_padding = 3
_item_bg_color = QtGui.QColor("#31424e")
def __init__(
self,
@ -433,14 +706,14 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox):
idxs = self._get_checked_idx()
# draw the icon and text
draw_text = True
draw_items = False
combotext = None
if self._custom_text is not None:
combotext = self._custom_text
elif not idxs:
combotext = self._placeholder_text
else:
draw_text = False
draw_items = True
content_field_rect = self.style().subControlRect(
QtWidgets.QStyle.CC_ComboBox,
@ -448,7 +721,9 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox):
QtWidgets.QStyle.SC_ComboBoxEditField
).adjusted(1, 0, -1, 0)
if draw_text:
if draw_items:
self._paint_items(painter, idxs, content_field_rect)
else:
color = option.palette.color(QtGui.QPalette.Text)
color.setAlpha(67)
pen = painter.pen()
@ -459,15 +734,12 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox):
QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter,
combotext
)
else:
self._paint_items(painter, idxs, content_field_rect)
painter.end()
def _paint_items(self, painter, indexes, content_rect):
origin_rect = QtCore.QRect(content_rect)
metrics = self.fontMetrics()
model = self.model()
available_width = content_rect.width()
total_used_width = 0
@ -482,31 +754,80 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox):
continue
icon = index.data(self._icon_role)
# TODO handle this case
if icon is None or icon.isNull():
continue
text = index.data(self._text_role)
valid_icon = icon is not None and not icon.isNull()
if valid_icon:
sizes = icon.availableSizes()
if sizes:
valid_icon = any(size.width() > 1 for size in sizes)
icon_rect = QtCore.QRect(content_rect)
diff = icon_rect.height() - metrics.height()
if diff < 0:
diff = 0
top_offset = diff // 2
bottom_offset = diff - top_offset
icon_rect.adjust(0, top_offset, 0, -bottom_offset)
icon_rect.setWidth(metrics.height())
icon.paint(
painter,
icon_rect,
QtCore.Qt.AlignCenter,
QtGui.QIcon.Normal,
QtGui.QIcon.On
)
content_rect.setLeft(icon_rect.right() + spacing)
if total_used_width > 0:
total_used_width += spacing
total_used_width += icon_rect.width()
if total_used_width > available_width:
break
if valid_icon:
metrics = self.fontMetrics()
icon_rect = QtCore.QRect(content_rect)
diff = icon_rect.height() - metrics.height()
if diff < 0:
diff = 0
top_offset = diff // 2
bottom_offset = diff - top_offset
icon_rect.adjust(0, top_offset, 0, -bottom_offset)
used_width = metrics.height()
if total_used_width > 0:
total_used_width += spacing
total_used_width += used_width
if total_used_width > available_width:
break
icon_rect.setWidth(used_width)
icon.paint(
painter,
icon_rect,
QtCore.Qt.AlignCenter,
QtGui.QIcon.Normal,
QtGui.QIcon.On
)
content_rect.setLeft(icon_rect.right() + spacing)
elif text:
bg_height = (
content_rect.height()
- (2 * self._top_bottom_margins)
)
font_height = bg_height - (2 * self._top_bottom_padding)
bg_top_y = content_rect.y() + self._top_bottom_margins
font = self.font()
font.setPixelSize(font_height)
metrics = QtGui.QFontMetrics(font)
painter.setFont(font)
label_rect = metrics.boundingRect(text)
bg_width = label_rect.width() + (2 * self._left_right_padding)
if total_used_width > 0:
total_used_width += spacing
total_used_width += bg_width
if total_used_width > available_width:
break
bg_rect = QtCore.QRectF(label_rect)
bg_rect.moveTop(bg_top_y)
bg_rect.moveLeft(content_rect.left())
bg_rect.setWidth(bg_width)
bg_rect.setHeight(bg_height)
label_rect.moveTop(bg_top_y)
label_rect.moveLeft(
content_rect.left() + self._left_right_padding
)
path = QtGui.QPainterPath()
path.addRoundedRect(bg_rect, 5, 5)
painter.fillPath(path, self._item_bg_color)
painter.drawText(label_rect, QtCore.Qt.AlignCenter, text)
content_rect.setLeft(bg_rect.right() + spacing)
painter.restore()

View file

@ -0,0 +1,169 @@
from qtpy import QtGui, QtCore
from ._multicombobox import (
CustomPaintMultiselectComboBox,
BaseQtModel,
)
STATUS_ITEM_TYPE = 0
SELECT_ALL_TYPE = 1
DESELECT_ALL_TYPE = 2
SWAP_STATE_TYPE = 3
PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 1
ITEM_TYPE_ROLE = QtCore.Qt.UserRole + 2
ITEM_SUBTYPE_ROLE = QtCore.Qt.UserRole + 3
class ProductTypesQtModel(BaseQtModel):
refreshed = QtCore.Signal()
def __init__(self, controller):
self._reset_filters_on_refresh = True
self._refreshing = False
self._bulk_change = False
self._items_by_name = {}
super().__init__(
item_type_role=ITEM_TYPE_ROLE,
item_subtype_role=ITEM_SUBTYPE_ROLE,
empty_values_label="No product types...",
controller=controller,
)
def is_refreshing(self):
return self._refreshing
def refresh(self, project_name):
self._refreshing = True
super().refresh(project_name)
self._reset_filters_on_refresh = False
self._refreshing = False
self.refreshed.emit()
def reset_product_types_filter_on_refresh(self):
self._reset_filters_on_refresh = True
def _get_standard_items(self) -> list[QtGui.QStandardItem]:
return list(self._items_by_name.values())
def _clear_standard_items(self):
self._items_by_name.clear()
def _prepare_new_value_items(self, project_name: str, _: bool) -> tuple[
list[QtGui.QStandardItem], list[QtGui.QStandardItem]
]:
product_type_items = self._controller.get_product_type_items(
project_name)
self._last_project = project_name
names_to_remove = set(self._items_by_name.keys())
items = []
items_filter_required = {}
for product_type_item in product_type_items:
name = product_type_item.name
names_to_remove.discard(name)
item = self._items_by_name.get(name)
# Apply filter to new items or if filters reset is requested
filter_required = self._reset_filters_on_refresh
if item is None:
filter_required = True
item = QtGui.QStandardItem(name)
item.setData(name, PRODUCT_TYPE_ROLE)
item.setEditable(False)
item.setCheckable(True)
self._items_by_name[name] = item
items.append(item)
if filter_required:
items_filter_required[name] = item
if items_filter_required:
product_types_filter = self._controller.get_product_types_filter()
for product_type, item in items_filter_required.items():
matching = (
int(product_type in product_types_filter.product_types)
+ int(product_types_filter.is_allow_list)
)
item.setCheckState(
QtCore.Qt.Checked
if matching % 2 == 0
else QtCore.Qt.Unchecked
)
items_to_remove = []
for name in names_to_remove:
items_to_remove.append(
self._items_by_name.pop(name)
)
# Uncheck all if all are checked (same result)
if all(
item.checkState() == QtCore.Qt.Checked
for item in items
):
for item in items:
item.setCheckState(QtCore.Qt.Unchecked)
return items, items_to_remove
class ProductTypesCombobox(CustomPaintMultiselectComboBox):
def __init__(self, controller, parent):
self._controller = controller
model = ProductTypesQtModel(controller)
super().__init__(
PRODUCT_TYPE_ROLE,
PRODUCT_TYPE_ROLE,
QtCore.Qt.ForegroundRole,
QtCore.Qt.DecorationRole,
item_type_role=ITEM_TYPE_ROLE,
model=model,
parent=parent
)
model.refreshed.connect(self._on_model_refresh)
self.set_placeholder_text("Product types filter...")
self._model = model
self._last_project_name = None
self._fully_disabled_filter = False
controller.register_event_callback(
"selection.project.changed",
self._on_project_change
)
controller.register_event_callback(
"projects.refresh.finished",
self._on_projects_refresh
)
self.setToolTip("Product types filter")
self.value_changed.connect(
self._on_product_type_filter_change
)
def reset_product_types_filter_on_refresh(self):
self._model.reset_product_types_filter_on_refresh()
def _on_model_refresh(self):
self.value_changed.emit()
def _on_product_type_filter_change(self):
lines = ["Product types filter"]
for item in self.get_value_info():
status_name, enabled = item
lines.append(f"{'' if enabled else ''} {status_name}")
self.setToolTip("\n".join(lines))
def _on_project_change(self, event):
project_name = event["project_name"]
self._last_project_name = project_name
self._model.refresh(project_name)
def _on_projects_refresh(self):
if self._last_project_name:
self._model.refresh(self._last_project_name)
self._on_product_type_filter_change()

View file

@ -1,256 +0,0 @@
from qtpy import QtWidgets, QtGui, QtCore
from ayon_core.tools.utils import get_qt_icon
PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 1
class ProductTypesQtModel(QtGui.QStandardItemModel):
refreshed = QtCore.Signal()
filter_changed = QtCore.Signal()
def __init__(self, controller):
super(ProductTypesQtModel, self).__init__()
self._controller = controller
self._reset_filters_on_refresh = True
self._refreshing = False
self._bulk_change = False
self._last_project = None
self._items_by_name = {}
controller.register_event_callback(
"controller.reset.finished",
self._on_controller_reset_finish,
)
def is_refreshing(self):
return self._refreshing
def get_filter_info(self):
"""Product types filtering info.
Returns:
dict[str, bool]: Filtering value by product type name. False value
means to hide product type.
"""
return {
name: item.checkState() == QtCore.Qt.Checked
for name, item in self._items_by_name.items()
}
def refresh(self, project_name):
self._refreshing = True
product_type_items = self._controller.get_product_type_items(
project_name)
self._last_project = project_name
items_to_remove = set(self._items_by_name.keys())
new_items = []
items_filter_required = {}
for product_type_item in product_type_items:
name = product_type_item.name
items_to_remove.discard(name)
item = self._items_by_name.get(name)
# Apply filter to new items or if filters reset is requested
filter_required = self._reset_filters_on_refresh
if item is None:
filter_required = True
item = QtGui.QStandardItem(name)
item.setData(name, PRODUCT_TYPE_ROLE)
item.setEditable(False)
item.setCheckable(True)
new_items.append(item)
self._items_by_name[name] = item
if filter_required:
items_filter_required[name] = item
icon = get_qt_icon(product_type_item.icon)
item.setData(icon, QtCore.Qt.DecorationRole)
if items_filter_required:
product_types_filter = self._controller.get_product_types_filter()
for product_type, item in items_filter_required.items():
matching = (
int(product_type in product_types_filter.product_types)
+ int(product_types_filter.is_allow_list)
)
state = (
QtCore.Qt.Checked
if matching % 2 == 0
else QtCore.Qt.Unchecked
)
item.setCheckState(state)
root_item = self.invisibleRootItem()
if new_items:
root_item.appendRows(new_items)
for name in items_to_remove:
item = self._items_by_name.pop(name)
root_item.removeRow(item.row())
self._reset_filters_on_refresh = False
self._refreshing = False
self.refreshed.emit()
def reset_product_types_filter_on_refresh(self):
self._reset_filters_on_refresh = True
def setData(self, index, value, role=None):
checkstate_changed = False
if role is None:
role = QtCore.Qt.EditRole
elif role == QtCore.Qt.CheckStateRole:
checkstate_changed = True
output = super(ProductTypesQtModel, self).setData(index, value, role)
if checkstate_changed and not self._bulk_change:
self.filter_changed.emit()
return output
def change_state_for_all(self, checked):
if self._items_by_name:
self.change_states(checked, self._items_by_name.keys())
def change_states(self, checked, product_types):
product_types = set(product_types)
if not product_types:
return
if checked is None:
state = None
elif checked:
state = QtCore.Qt.Checked
else:
state = QtCore.Qt.Unchecked
self._bulk_change = True
changed = False
for product_type in product_types:
item = self._items_by_name.get(product_type)
if item is None:
continue
new_state = state
item_checkstate = item.checkState()
if new_state is None:
if item_checkstate == QtCore.Qt.Checked:
new_state = QtCore.Qt.Unchecked
else:
new_state = QtCore.Qt.Checked
elif item_checkstate == new_state:
continue
changed = True
item.setCheckState(new_state)
self._bulk_change = False
if changed:
self.filter_changed.emit()
def _on_controller_reset_finish(self):
self.refresh(self._last_project)
class ProductTypesView(QtWidgets.QListView):
filter_changed = QtCore.Signal()
def __init__(self, controller, parent):
super(ProductTypesView, self).__init__(parent)
self.setSelectionMode(
QtWidgets.QAbstractItemView.ExtendedSelection
)
self.setAlternatingRowColors(True)
self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
product_types_model = ProductTypesQtModel(controller)
product_types_proxy_model = QtCore.QSortFilterProxyModel()
product_types_proxy_model.setSourceModel(product_types_model)
self.setModel(product_types_proxy_model)
product_types_model.refreshed.connect(self._on_refresh_finished)
product_types_model.filter_changed.connect(self._on_filter_change)
self.customContextMenuRequested.connect(self._on_context_menu)
controller.register_event_callback(
"selection.project.changed",
self._on_project_change
)
self._controller = controller
self._refresh_product_types_filter = False
self._product_types_model = product_types_model
self._product_types_proxy_model = product_types_proxy_model
def get_filter_info(self):
return self._product_types_model.get_filter_info()
def reset_product_types_filter_on_refresh(self):
self._product_types_model.reset_product_types_filter_on_refresh()
def _on_project_change(self, event):
project_name = event["project_name"]
self._product_types_model.refresh(project_name)
def _on_refresh_finished(self):
# Apply product types filter on first show
self.filter_changed.emit()
def _on_filter_change(self):
if not self._product_types_model.is_refreshing():
self.filter_changed.emit()
def _change_selection_state(self, checkstate):
selection_model = self.selectionModel()
product_types = {
index.data(PRODUCT_TYPE_ROLE)
for index in selection_model.selectedIndexes()
}
product_types.discard(None)
self._product_types_model.change_states(checkstate, product_types)
def _on_enable_all(self):
self._product_types_model.change_state_for_all(True)
def _on_disable_all(self):
self._product_types_model.change_state_for_all(False)
def _on_context_menu(self, pos):
menu = QtWidgets.QMenu(self)
# Add enable all action
action_check_all = QtWidgets.QAction(menu)
action_check_all.setText("Enable All")
action_check_all.triggered.connect(self._on_enable_all)
# Add disable all action
action_uncheck_all = QtWidgets.QAction(menu)
action_uncheck_all.setText("Disable All")
action_uncheck_all.triggered.connect(self._on_disable_all)
menu.addAction(action_check_all)
menu.addAction(action_uncheck_all)
# Get mouse position
global_pos = self.viewport().mapToGlobal(pos)
menu.exec_(global_pos)
def event(self, event):
if event.type() == QtCore.QEvent.KeyPress:
if event.key() == QtCore.Qt.Key_Space:
self._change_selection_state(None)
return True
if event.key() == QtCore.Qt.Key_Backspace:
self._change_selection_state(False)
return True
if event.key() == QtCore.Qt.Key_Return:
self._change_selection_state(True)
return True
return super(ProductTypesView, self).event(event)

View file

@ -19,6 +19,7 @@ from .products_model import (
)
STATUS_NAME_ROLE = QtCore.Qt.UserRole + 1
TASK_ID_ROLE = QtCore.Qt.UserRole + 2
class VersionsModel(QtGui.QStandardItemModel):
@ -48,6 +49,7 @@ class VersionsModel(QtGui.QStandardItemModel):
item.setData(version_id, QtCore.Qt.UserRole)
self._items_by_id[version_id] = item
item.setData(version_item.status, STATUS_NAME_ROLE)
item.setData(version_item.task_id, TASK_ID_ROLE)
if item.row() != idx:
root_item.insertRow(idx, item)
@ -57,17 +59,30 @@ class VersionsFilterModel(QtCore.QSortFilterProxyModel):
def __init__(self):
super().__init__()
self._status_filter = None
self._task_ids_filter = None
def filterAcceptsRow(self, row, parent):
if self._status_filter is None:
return True
if self._status_filter is not None:
if not self._status_filter:
return False
if not self._status_filter:
return False
index = self.sourceModel().index(row, 0, parent)
status = index.data(STATUS_NAME_ROLE)
if status not in self._status_filter:
return False
index = self.sourceModel().index(row, 0, parent)
status = index.data(STATUS_NAME_ROLE)
return status in self._status_filter
if self._task_ids_filter:
index = self.sourceModel().index(row, 0, parent)
task_id = index.data(TASK_ID_ROLE)
if task_id not in self._task_ids_filter:
return False
return True
def set_tasks_filter(self, task_ids):
if self._task_ids_filter == task_ids:
return
self._task_ids_filter = task_ids
self.invalidateFilter()
def set_statuses_filter(self, status_names):
if self._status_filter == status_names:
@ -101,6 +116,13 @@ class VersionComboBox(QtWidgets.QComboBox):
def get_product_id(self):
return self._product_id
def set_tasks_filter(self, task_ids):
self._proxy_model.set_tasks_filter(task_ids)
if self.count() == 0:
return
if self.currentIndex() != 0:
self.setCurrentIndex(0)
def set_statuses_filter(self, status_names):
self._proxy_model.set_statuses_filter(status_names)
if self.count() == 0:
@ -149,6 +171,7 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate):
super().__init__(*args, **kwargs)
self._editor_by_id: Dict[str, VersionComboBox] = {}
self._task_ids_filter = None
self._statuses_filter = None
def displayText(self, value, locale):
@ -156,6 +179,11 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate):
return "N/A"
return format_version(value)
def set_tasks_filter(self, task_ids):
self._task_ids_filter = set(task_ids)
for widget in self._editor_by_id.values():
widget.set_tasks_filter(task_ids)
def set_statuses_filter(self, status_names):
self._statuses_filter = set(status_names)
for widget in self._editor_by_id.values():
@ -239,6 +267,7 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate):
version_id = index.data(VERSION_ID_ROLE)
editor.update_versions(versions, version_id)
editor.set_tasks_filter(self._task_ids_filter)
editor.set_statuses_filter(self._statuses_filter)
def setModelData(self, editor, model, index):

View file

@ -12,34 +12,35 @@ GROUP_TYPE_ROLE = QtCore.Qt.UserRole + 1
MERGED_COLOR_ROLE = QtCore.Qt.UserRole + 2
FOLDER_LABEL_ROLE = QtCore.Qt.UserRole + 3
FOLDER_ID_ROLE = QtCore.Qt.UserRole + 4
PRODUCT_ID_ROLE = QtCore.Qt.UserRole + 5
PRODUCT_NAME_ROLE = QtCore.Qt.UserRole + 6
PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 7
PRODUCT_TYPE_ICON_ROLE = QtCore.Qt.UserRole + 8
PRODUCT_IN_SCENE_ROLE = QtCore.Qt.UserRole + 9
VERSION_ID_ROLE = QtCore.Qt.UserRole + 10
VERSION_HERO_ROLE = QtCore.Qt.UserRole + 11
VERSION_NAME_ROLE = QtCore.Qt.UserRole + 12
VERSION_NAME_EDIT_ROLE = QtCore.Qt.UserRole + 13
VERSION_PUBLISH_TIME_ROLE = QtCore.Qt.UserRole + 14
VERSION_STATUS_NAME_ROLE = QtCore.Qt.UserRole + 15
VERSION_STATUS_SHORT_ROLE = QtCore.Qt.UserRole + 16
VERSION_STATUS_COLOR_ROLE = QtCore.Qt.UserRole + 17
VERSION_STATUS_ICON_ROLE = QtCore.Qt.UserRole + 18
VERSION_AUTHOR_ROLE = QtCore.Qt.UserRole + 19
VERSION_FRAME_RANGE_ROLE = QtCore.Qt.UserRole + 20
VERSION_DURATION_ROLE = QtCore.Qt.UserRole + 21
VERSION_HANDLES_ROLE = QtCore.Qt.UserRole + 22
VERSION_STEP_ROLE = QtCore.Qt.UserRole + 23
VERSION_AVAILABLE_ROLE = QtCore.Qt.UserRole + 24
VERSION_THUMBNAIL_ID_ROLE = QtCore.Qt.UserRole + 25
ACTIVE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 26
REMOTE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 27
REPRESENTATIONS_COUNT_ROLE = QtCore.Qt.UserRole + 28
SYNC_ACTIVE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 29
SYNC_REMOTE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 30
TASK_ID_ROLE = QtCore.Qt.UserRole + 5
PRODUCT_ID_ROLE = QtCore.Qt.UserRole + 6
PRODUCT_NAME_ROLE = QtCore.Qt.UserRole + 7
PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 8
PRODUCT_TYPE_ICON_ROLE = QtCore.Qt.UserRole + 9
PRODUCT_IN_SCENE_ROLE = QtCore.Qt.UserRole + 10
VERSION_ID_ROLE = QtCore.Qt.UserRole + 11
VERSION_HERO_ROLE = QtCore.Qt.UserRole + 12
VERSION_NAME_ROLE = QtCore.Qt.UserRole + 13
VERSION_NAME_EDIT_ROLE = QtCore.Qt.UserRole + 14
VERSION_PUBLISH_TIME_ROLE = QtCore.Qt.UserRole + 15
VERSION_STATUS_NAME_ROLE = QtCore.Qt.UserRole + 16
VERSION_STATUS_SHORT_ROLE = QtCore.Qt.UserRole + 17
VERSION_STATUS_COLOR_ROLE = QtCore.Qt.UserRole + 18
VERSION_STATUS_ICON_ROLE = QtCore.Qt.UserRole + 19
VERSION_AUTHOR_ROLE = QtCore.Qt.UserRole + 20
VERSION_FRAME_RANGE_ROLE = QtCore.Qt.UserRole + 21
VERSION_DURATION_ROLE = QtCore.Qt.UserRole + 22
VERSION_HANDLES_ROLE = QtCore.Qt.UserRole + 23
VERSION_STEP_ROLE = QtCore.Qt.UserRole + 24
VERSION_AVAILABLE_ROLE = QtCore.Qt.UserRole + 25
VERSION_THUMBNAIL_ID_ROLE = QtCore.Qt.UserRole + 26
ACTIVE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 27
REMOTE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 28
REPRESENTATIONS_COUNT_ROLE = QtCore.Qt.UserRole + 29
SYNC_ACTIVE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 30
SYNC_REMOTE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 31
STATUS_NAME_FILTER_ROLE = QtCore.Qt.UserRole + 31
STATUS_NAME_FILTER_ROLE = QtCore.Qt.UserRole + 32
class ProductsModel(QtGui.QStandardItemModel):
@ -368,6 +369,7 @@ class ProductsModel(QtGui.QStandardItemModel):
"""
model_item.setData(version_item.version_id, VERSION_ID_ROLE)
model_item.setData(version_item.task_id, TASK_ID_ROLE)
model_item.setData(version_item.version, VERSION_NAME_ROLE)
model_item.setData(version_item.is_hero, VERSION_HERO_ROLE)
model_item.setData(

View file

@ -1,4 +1,6 @@
from __future__ import annotations
import collections
from typing import Optional
from qtpy import QtWidgets, QtCore
@ -15,6 +17,7 @@ from .products_model import (
GROUP_TYPE_ROLE,
MERGED_COLOR_ROLE,
FOLDER_ID_ROLE,
TASK_ID_ROLE,
PRODUCT_ID_ROLE,
VERSION_ID_ROLE,
VERSION_STATUS_NAME_ROLE,
@ -36,8 +39,9 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel):
def __init__(self, parent=None):
super().__init__(parent)
self._product_type_filters = {}
self._product_type_filters = None
self._statuses_filter = None
self._task_ids_filter = None
self._ascending_sort = True
def get_statuses_filter(self):
@ -45,7 +49,15 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel):
return None
return set(self._statuses_filter)
def set_tasks_filter(self, task_ids_filter):
if self._task_ids_filter == task_ids_filter:
return
self._task_ids_filter = task_ids_filter
self.invalidateFilter()
def set_product_type_filters(self, product_type_filters):
if self._product_type_filters == product_type_filters:
return
self._product_type_filters = product_type_filters
self.invalidateFilter()
@ -58,29 +70,41 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel):
def filterAcceptsRow(self, source_row, source_parent):
source_model = self.sourceModel()
index = source_model.index(source_row, 0, source_parent)
product_types_s = source_model.data(index, PRODUCT_TYPE_ROLE)
product_types = []
if product_types_s:
product_types = product_types_s.split("|")
for product_type in product_types:
if not self._product_type_filters.get(product_type, True):
return False
if not self._accept_row_by_statuses(index):
if not self._accept_task_ids_filter(index):
return False
if not self._accept_row_by_role_value(
index, self._product_type_filters, PRODUCT_TYPE_ROLE
):
return False
if not self._accept_row_by_role_value(
index, self._statuses_filter, STATUS_NAME_FILTER_ROLE
):
return False
return super().filterAcceptsRow(source_row, source_parent)
def _accept_row_by_statuses(self, index):
if self._statuses_filter is None:
def _accept_task_ids_filter(self, index):
if not self._task_ids_filter:
return True
if not self._statuses_filter:
task_id = index.data(TASK_ID_ROLE)
return task_id in self._task_ids_filter
def _accept_row_by_role_value(
self,
index: QtCore.QModelIndex,
filter_value: Optional[set[str]],
role: int
):
if filter_value is None:
return True
if not filter_value:
return False
status_s = index.data(STATUS_NAME_FILTER_ROLE)
status_s = index.data(role)
for status in status_s.split("|"):
if status in self._statuses_filter:
if status in filter_value:
return True
return False
@ -120,7 +144,7 @@ class ProductsWidget(QtWidgets.QWidget):
90, # Product type
130, # Folder label
60, # Version
100, # Status
100, # Status
125, # Time
75, # Author
75, # Frames
@ -246,6 +270,16 @@ class ProductsWidget(QtWidgets.QWidget):
"""
self._products_proxy_model.setFilterFixedString(name)
def set_tasks_filter(self, task_ids):
"""Set filter of version tasks.
Args:
task_ids (set[str]): Task ids.
"""
self._version_delegate.set_tasks_filter(task_ids)
self._products_proxy_model.set_tasks_filter(task_ids)
def set_statuses_filter(self, status_names):
"""Set filter of version statuses.

View file

@ -1,4 +1,4 @@
from typing import List, Dict
from __future__ import annotations
from qtpy import QtCore, QtGui
@ -7,7 +7,7 @@ from ayon_core.tools.common_models import StatusItem
from ._multicombobox import (
CustomPaintMultiselectComboBox,
STANDARD_ITEM_TYPE,
BaseQtModel,
)
STATUS_ITEM_TYPE = 0
@ -24,62 +24,43 @@ ITEM_TYPE_ROLE = QtCore.Qt.UserRole + 5
ITEM_SUBTYPE_ROLE = QtCore.Qt.UserRole + 6
class StatusesQtModel(QtGui.QStandardItemModel):
class StatusesQtModel(BaseQtModel):
def __init__(self, controller):
self._controller = controller
self._items_by_name: Dict[str, QtGui.QStandardItem] = {}
self._icons_by_name_n_color: Dict[str, QtGui.QIcon] = {}
self._last_project = None
self._items_by_name: dict[str, QtGui.QStandardItem] = {}
self._icons_by_name_n_color: dict[str, QtGui.QIcon] = {}
super().__init__(
ITEM_TYPE_ROLE,
ITEM_SUBTYPE_ROLE,
"No statuses...",
controller,
)
self._select_project_item = None
self._empty_statuses_item = None
def _get_standard_items(self) -> list[QtGui.QStandardItem]:
return list(self._items_by_name.values())
self._select_all_item = None
self._deselect_all_item = None
self._swap_states_item = None
def _clear_standard_items(self):
self._items_by_name.clear()
super().__init__()
self.refresh(None)
def get_placeholder_text(self):
return self._placeholder
def refresh(self, project_name):
# New project was selected
# status filter is reset to show all statuses
uncheck_all = False
if project_name != self._last_project:
self._last_project = project_name
uncheck_all = True
if project_name is None:
self._add_select_project_item()
return
status_items: List[StatusItem] = (
def _prepare_new_value_items(
self, project_name: str, project_changed: bool
):
status_items: list[StatusItem] = (
self._controller.get_project_status_items(
project_name, sender=STATUSES_FILTER_SENDER
)
)
items = []
items_to_remove = []
if not status_items:
self._add_empty_statuses_item()
return
return items, items_to_remove
self._remove_empty_items()
items_to_remove = set(self._items_by_name)
root_item = self.invisibleRootItem()
names_to_remove = set(self._items_by_name)
for row_idx, status_item in enumerate(status_items):
name = status_item.name
if name in self._items_by_name:
is_new = False
item = self._items_by_name[name]
if uncheck_all:
item.setCheckState(QtCore.Qt.Unchecked)
items_to_remove.discard(name)
names_to_remove.discard(name)
else:
is_new = True
item = QtGui.QStandardItem()
item.setData(ITEM_SUBTYPE_ROLE, STATUS_ITEM_TYPE)
item.setCheckState(QtCore.Qt.Unchecked)
@ -100,36 +81,14 @@ class StatusesQtModel(QtGui.QStandardItemModel):
if item.data(role) != value:
item.setData(value, role)
if is_new:
root_item.insertRow(row_idx, item)
if project_changed:
item.setCheckState(QtCore.Qt.Unchecked)
items.append(item)
for name in items_to_remove:
item = self._items_by_name.pop(name)
root_item.removeRow(item.row())
for name in names_to_remove:
items_to_remove.append(self._items_by_name.pop(name))
self._add_selection_items()
def setData(self, index, value, role):
if role == QtCore.Qt.CheckStateRole and index.isValid():
item_type = index.data(ITEM_SUBTYPE_ROLE)
if item_type == SELECT_ALL_TYPE:
for item in self._items_by_name.values():
item.setCheckState(QtCore.Qt.Checked)
return True
if item_type == DESELECT_ALL_TYPE:
for item in self._items_by_name.values():
item.setCheckState(QtCore.Qt.Unchecked)
return True
if item_type == SWAP_STATE_TYPE:
for item in self._items_by_name.values():
current_state = item.checkState()
item.setCheckState(
QtCore.Qt.Checked
if current_state == QtCore.Qt.Unchecked
else QtCore.Qt.Unchecked
)
return True
return super().setData(index, value, role)
return items, items_to_remove
def _get_icon(self, status_item: StatusItem) -> QtGui.QIcon:
name = status_item.name
@ -147,139 +106,6 @@ class StatusesQtModel(QtGui.QStandardItemModel):
self._icons_by_name_n_color[unique_id] = icon
return icon
def _init_default_items(self):
if self._empty_statuses_item is not None:
return
empty_statuses_item = QtGui.QStandardItem("No statuses...")
select_project_item = QtGui.QStandardItem("Select project...")
select_all_item = QtGui.QStandardItem("Select all")
deselect_all_item = QtGui.QStandardItem("Deselect all")
swap_states_item = QtGui.QStandardItem("Swap")
for item in (
empty_statuses_item,
select_project_item,
select_all_item,
deselect_all_item,
swap_states_item,
):
item.setData(STANDARD_ITEM_TYPE, ITEM_TYPE_ROLE)
select_all_item.setIcon(get_qt_icon({
"type": "material-symbols",
"name": "done_all",
"color": "white"
}))
deselect_all_item.setIcon(get_qt_icon({
"type": "material-symbols",
"name": "remove_done",
"color": "white"
}))
swap_states_item.setIcon(get_qt_icon({
"type": "material-symbols",
"name": "swap_horiz",
"color": "white"
}))
for item in (
empty_statuses_item,
select_project_item,
):
item.setFlags(QtCore.Qt.NoItemFlags)
for item, item_type in (
(select_all_item, SELECT_ALL_TYPE),
(deselect_all_item, DESELECT_ALL_TYPE),
(swap_states_item, SWAP_STATE_TYPE),
):
item.setData(item_type, ITEM_SUBTYPE_ROLE)
for item in (
select_all_item,
deselect_all_item,
swap_states_item,
):
item.setFlags(
QtCore.Qt.ItemIsEnabled
| QtCore.Qt.ItemIsSelectable
| QtCore.Qt.ItemIsUserCheckable
)
self._empty_statuses_item = empty_statuses_item
self._select_project_item = select_project_item
self._select_all_item = select_all_item
self._deselect_all_item = deselect_all_item
self._swap_states_item = swap_states_item
def _get_empty_statuses_item(self):
self._init_default_items()
return self._empty_statuses_item
def _get_select_project_item(self):
self._init_default_items()
return self._select_project_item
def _get_empty_items(self):
self._init_default_items()
return [
self._empty_statuses_item,
self._select_project_item,
]
def _get_selection_items(self):
self._init_default_items()
return [
self._select_all_item,
self._deselect_all_item,
self._swap_states_item,
]
def _get_default_items(self):
return self._get_empty_items() + self._get_selection_items()
def _add_select_project_item(self):
item = self._get_select_project_item()
if item.row() < 0:
self._remove_items()
root_item = self.invisibleRootItem()
root_item.appendRow(item)
def _add_empty_statuses_item(self):
item = self._get_empty_statuses_item()
if item.row() < 0:
self._remove_items()
root_item = self.invisibleRootItem()
root_item.appendRow(item)
def _add_selection_items(self):
root_item = self.invisibleRootItem()
items = self._get_selection_items()
for item in self._get_selection_items():
row = item.row()
if row >= 0:
root_item.takeRow(row)
root_item.appendRows(items)
def _remove_items(self):
root_item = self.invisibleRootItem()
for item in self._get_default_items():
if item.row() < 0:
continue
root_item.takeRow(item.row())
root_item.removeRows(0, root_item.rowCount())
self._items_by_name.clear()
def _remove_empty_items(self):
root_item = self.invisibleRootItem()
for item in self._get_empty_items():
if item.row() < 0:
continue
root_item.takeRow(item.row())
class StatusesCombobox(CustomPaintMultiselectComboBox):
def __init__(self, controller, parent):

View file

@ -0,0 +1,405 @@
import collections
import hashlib
from qtpy import QtWidgets, QtCore, QtGui
from ayon_core.style import get_default_entity_icon_color
from ayon_core.tools.utils import (
RecursiveSortFilterProxyModel,
DeselectableTreeView,
TasksQtModel,
TASKS_MODEL_SENDER_NAME,
)
from ayon_core.tools.utils.tasks_widget import (
ITEM_ID_ROLE,
ITEM_NAME_ROLE,
PARENT_ID_ROLE,
TASK_TYPE_ROLE,
)
from ayon_core.tools.utils.lib import RefreshThread, get_qt_icon
# Role that can't clash with default 'tasks_widget' roles
FOLDER_LABEL_ROLE = QtCore.Qt.UserRole + 100
NO_TASKS_ID = "--no-task--"
class LoaderTasksQtModel(TasksQtModel):
column_labels = [
"Task name",
"Task type",
"Folder"
]
def __init__(self, controller):
super().__init__(controller)
self._items_by_id = {}
self._groups_by_name = {}
self._last_folder_ids = set()
# This item is used to be able filter versions without any task
# - do not mismatch with '_empty_tasks_item' item from 'TasksQtModel'
self._no_tasks_item = None
def refresh(self):
"""Refresh tasks for selected folders."""
self._refresh(self._last_project_name, self._last_folder_ids)
def set_context(self, project_name, folder_ids):
self._refresh(project_name, folder_ids)
# Mark some functions from 'TasksQtModel' as not implemented
def get_index_by_name(self, task_name):
raise NotImplementedError(
"Method 'get_index_by_name' is not implemented."
)
def get_last_folder_id(self):
raise NotImplementedError(
"Method 'get_last_folder_id' is not implemented."
)
def flags(self, index):
if index.column() != 0:
index = self.index(index.row(), 0, index.parent())
return super().flags(index)
def _get_no_tasks_item(self):
if self._no_tasks_item is None:
item = QtGui.QStandardItem("No task")
icon = get_qt_icon({
"type": "material-symbols",
"name": "indeterminate_check_box",
"color": get_default_entity_icon_color(),
})
item.setData(icon, QtCore.Qt.DecorationRole)
item.setData(NO_TASKS_ID, ITEM_ID_ROLE)
item.setEditable(False)
self._no_tasks_item = item
return self._no_tasks_item
def _refresh(self, project_name, folder_ids):
self._is_refreshing = True
self._last_project_name = project_name
self._last_folder_ids = folder_ids
if not folder_ids:
self._add_invalid_selection_item()
self._current_refresh_thread = None
self._is_refreshing = False
self.refreshed.emit()
return
thread_id = hashlib.sha256(
"|".join(sorted(folder_ids)).encode()
).hexdigest()
thread = self._refresh_threads.get(thread_id)
if thread is not None:
self._current_refresh_thread = thread
return
thread = RefreshThread(
thread_id,
self._thread_getter,
project_name,
folder_ids
)
self._current_refresh_thread = thread
self._refresh_threads[thread.id] = thread
thread.refresh_finished.connect(self._on_refresh_thread)
thread.start()
def _thread_getter(self, project_name, folder_ids):
task_items = self._controller.get_task_items(
project_name, folder_ids, sender=TASKS_MODEL_SENDER_NAME
)
task_type_items = {}
if hasattr(self._controller, "get_task_type_items"):
task_type_items = self._controller.get_task_type_items(
project_name, sender=TASKS_MODEL_SENDER_NAME
)
folder_ids = {
task_item.parent_id
for task_item in task_items
}
folder_labels_by_id = self._controller.get_folder_labels(
project_name, folder_ids
)
return task_items, task_type_items, folder_labels_by_id
def _on_refresh_thread(self, thread_id):
"""Callback when refresh thread is finished.
Technically can be running multiple refresh threads at the same time,
to avoid using values from wrong thread, we check if thread id is
current refresh thread id.
Tasks are stored by name, so if a folder has same task name as
previously selected folder it keeps the selection.
Args:
thread_id (str): Thread id.
"""
# Make sure to remove thread from '_refresh_threads' dict
thread = self._refresh_threads.pop(thread_id)
if (
self._current_refresh_thread is None
or thread_id != self._current_refresh_thread.id
):
return
self._fill_data_from_thread(thread)
root_item = self.invisibleRootItem()
self._has_content = root_item.rowCount() > 0
self._current_refresh_thread = None
self._is_refreshing = False
self.refreshed.emit()
def _clear_items(self):
self._items_by_id = {}
self._groups_by_name = {}
super()._clear_items()
def _fill_data_from_thread(self, thread):
task_items, task_type_items, folder_labels_by_id = thread.get_result()
# Task items are refreshed
if task_items is None:
return
# No tasks are available on folder
if not task_items:
self._add_empty_task_item()
return
self._remove_invalid_items()
task_type_item_by_name = {
task_type_item.name: task_type_item
for task_type_item in task_type_items
}
task_type_icon_cache = {}
current_ids = set()
items_by_name = collections.defaultdict(list)
for task_item in task_items:
task_id = task_item.task_id
current_ids.add(task_id)
item = self._items_by_id.get(task_id)
if item is None:
item = QtGui.QStandardItem()
item.setColumnCount(self.columnCount())
item.setEditable(False)
self._items_by_id[task_id] = item
icon = self._get_task_item_icon(
task_item,
task_type_item_by_name,
task_type_icon_cache
)
name = task_item.name
folder_id = task_item.parent_id
folder_label = folder_labels_by_id.get(folder_id)
item.setData(name, QtCore.Qt.DisplayRole)
item.setData(name, ITEM_NAME_ROLE)
item.setData(task_item.id, ITEM_ID_ROLE)
item.setData(task_item.task_type, TASK_TYPE_ROLE)
item.setData(folder_id, PARENT_ID_ROLE)
item.setData(folder_label, FOLDER_LABEL_ROLE)
item.setData(icon, QtCore.Qt.DecorationRole)
items_by_name[name].append(item)
root_item = self.invisibleRootItem()
# Make sure item is not parented
# - this is laziness to avoid re-parenting items which does
# complicate the code with no benefit
queue = collections.deque()
queue.append((None, root_item))
while queue:
(parent, item) = queue.popleft()
if not item.hasChildren():
if parent:
parent.takeRow(item.row())
continue
for row in range(item.rowCount()):
queue.append((item, item.child(row, 0)))
queue.append((parent, item))
used_group_names = set()
new_root_items = [
self._get_no_tasks_item()
]
for name, items in items_by_name.items():
if len(items) == 1:
new_root_items.extend(items)
continue
used_group_names.add(name)
group_item = self._groups_by_name.get(name)
if group_item is None:
group_item = QtGui.QStandardItem()
group_item.setData(name, QtCore.Qt.DisplayRole)
group_item.setEditable(False)
group_item.setColumnCount(self.columnCount())
self._groups_by_name[name] = group_item
# Use icon from first item
first_item_icon = items[0].data(QtCore.Qt.DecorationRole)
task_ids = [
item.data(ITEM_ID_ROLE)
for item in items
]
group_item.setData(first_item_icon, QtCore.Qt.DecorationRole)
group_item.setData("|".join(task_ids), ITEM_ID_ROLE)
group_item.appendRows(items)
new_root_items.append(group_item)
# Remove unused caches
for task_id in set(self._items_by_id) - current_ids:
self._items_by_id.pop(task_id)
for name in set(self._groups_by_name) - used_group_names:
self._groups_by_name.pop(name)
if new_root_items:
root_item.appendRows(new_root_items)
def data(self, index, role=None):
if not index.isValid():
return None
if role is None:
role = QtCore.Qt.DisplayRole
col = index.column()
if col != 0:
index = self.index(index.row(), 0, index.parent())
if col == 1:
if role == QtCore.Qt.DisplayRole:
role = TASK_TYPE_ROLE
else:
return None
if col == 2:
if role == QtCore.Qt.DisplayRole:
role = FOLDER_LABEL_ROLE
else:
return None
return super().data(index, role)
class LoaderTasksProxyModel(RecursiveSortFilterProxyModel):
def lessThan(self, left, right):
if left.data(ITEM_ID_ROLE) == NO_TASKS_ID:
return False
if right.data(ITEM_ID_ROLE) == NO_TASKS_ID:
return True
return super().lessThan(left, right)
class LoaderTasksWidget(QtWidgets.QWidget):
refreshed = QtCore.Signal()
def __init__(self, controller, parent):
super().__init__(parent)
tasks_view = DeselectableTreeView(self)
tasks_view.setSelectionMode(
QtWidgets.QAbstractItemView.ExtendedSelection
)
tasks_model = LoaderTasksQtModel(controller)
tasks_proxy_model = LoaderTasksProxyModel()
tasks_proxy_model.setSourceModel(tasks_model)
tasks_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive)
tasks_view.setModel(tasks_proxy_model)
# Hide folder column by default
tasks_view.setColumnHidden(2, True)
main_layout = QtWidgets.QHBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.addWidget(tasks_view, 1)
controller.register_event_callback(
"selection.folders.changed",
self._on_folders_selection_changed,
)
controller.register_event_callback(
"tasks.refresh.finished",
self._on_tasks_refresh_finished
)
selection_model = tasks_view.selectionModel()
selection_model.selectionChanged.connect(self._on_selection_change)
tasks_model.refreshed.connect(self._on_model_refresh)
self._controller = controller
self._tasks_view = tasks_view
self._tasks_model = tasks_model
self._tasks_proxy_model = tasks_proxy_model
self._fisrt_show = True
def showEvent(self, event):
super().showEvent(event)
if self._fisrt_show:
self._fisrt_show = False
header_widget = self._tasks_view.header()
header_widget.resizeSection(0, 200)
def set_name_filter(self, name):
"""Set filter of folder name.
Args:
name (str): The string filter.
"""
self._tasks_proxy_model.setFilterFixedString(name)
if name:
self._tasks_view.expandAll()
def refresh(self):
self._tasks_model.refresh()
def _clear(self):
self._tasks_model.clear()
def _on_tasks_refresh_finished(self, event):
if event["sender"] != TASKS_MODEL_SENDER_NAME:
self._set_project_name(event["project_name"])
def _on_folders_selection_changed(self, event):
project_name = event["project_name"]
folder_ids = event["folder_ids"]
self._tasks_view.setColumnHidden(2, len(folder_ids) == 1)
self._tasks_model.set_context(project_name, folder_ids)
def _on_model_refresh(self):
self._tasks_proxy_model.sort(0)
self.refreshed.emit()
def _get_selected_item_ids(self):
selection_model = self._tasks_view.selectionModel()
item_ids = set()
for index in selection_model.selectedIndexes():
item_id = index.data(ITEM_ID_ROLE)
if item_id is None:
continue
if item_id == NO_TASKS_ID:
item_ids.add(None)
else:
item_ids |= set(item_id.split("|"))
return item_ids
def _on_selection_change(self):
item_ids = self._get_selected_item_ids()
self._controller.set_selected_tasks(item_ids)

View file

@ -14,8 +14,9 @@ from ayon_core.tools.utils import ProjectsCombobox
from ayon_core.tools.loader.control import LoaderController
from .folders_widget import LoaderFoldersWidget
from .tasks_widget import LoaderTasksWidget
from .products_widget import ProductsWidget
from .product_types_widget import ProductTypesView
from .product_types_combo import ProductTypesCombobox
from .product_group_dialog import ProductGroupDialog
from .info_widget import InfoWidget
from .repres_widget import RepresentationsWidget
@ -164,16 +165,16 @@ class LoaderWindow(QtWidgets.QWidget):
folders_widget = LoaderFoldersWidget(controller, context_widget)
product_types_widget = ProductTypesView(controller, context_splitter)
context_layout = QtWidgets.QVBoxLayout(context_widget)
context_layout.setContentsMargins(0, 0, 0, 0)
context_layout.addWidget(context_top_widget, 0)
context_layout.addWidget(folders_filter_input, 0)
context_layout.addWidget(folders_widget, 1)
tasks_widget = LoaderTasksWidget(controller, context_widget)
context_splitter.addWidget(context_widget)
context_splitter.addWidget(product_types_widget)
context_splitter.addWidget(tasks_widget)
context_splitter.setStretchFactor(0, 65)
context_splitter.setStretchFactor(1, 35)
@ -185,6 +186,10 @@ class LoaderWindow(QtWidgets.QWidget):
products_filter_input = PlaceholderLineEdit(products_inputs_widget)
products_filter_input.setPlaceholderText("Product name filter...")
product_types_filter_combo = ProductTypesCombobox(
controller, products_inputs_widget
)
product_status_filter_combo = StatusesCombobox(controller, self)
product_group_checkbox = QtWidgets.QCheckBox(
@ -196,6 +201,7 @@ class LoaderWindow(QtWidgets.QWidget):
products_inputs_layout = QtWidgets.QHBoxLayout(products_inputs_widget)
products_inputs_layout.setContentsMargins(0, 0, 0, 0)
products_inputs_layout.addWidget(products_filter_input, 1)
products_inputs_layout.addWidget(product_types_filter_combo, 1)
products_inputs_layout.addWidget(product_status_filter_combo, 1)
products_inputs_layout.addWidget(product_group_checkbox, 0)
@ -244,12 +250,12 @@ class LoaderWindow(QtWidgets.QWidget):
folders_filter_input.textChanged.connect(
self._on_folder_filter_change
)
product_types_widget.filter_changed.connect(
self._on_product_type_filter_change
)
products_filter_input.textChanged.connect(
self._on_product_filter_change
)
product_types_filter_combo.value_changed.connect(
self._on_product_type_filter_change
)
product_status_filter_combo.value_changed.connect(
self._on_status_filter_change
)
@ -280,6 +286,10 @@ class LoaderWindow(QtWidgets.QWidget):
"selection.folders.changed",
self._on_folders_selection_changed,
)
controller.register_event_callback(
"selection.tasks.changed",
self._on_tasks_selection_change,
)
controller.register_event_callback(
"selection.versions.changed",
self._on_versions_selection_changed,
@ -304,9 +314,10 @@ class LoaderWindow(QtWidgets.QWidget):
self._folders_filter_input = folders_filter_input
self._folders_widget = folders_widget
self._product_types_widget = product_types_widget
self._tasks_widget = tasks_widget
self._products_filter_input = products_filter_input
self._product_types_filter_combo = product_types_filter_combo
self._product_status_filter_combo = product_status_filter_combo
self._product_group_checkbox = product_group_checkbox
self._products_widget = products_widget
@ -335,7 +346,7 @@ class LoaderWindow(QtWidgets.QWidget):
self._controller.reset()
def showEvent(self, event):
super(LoaderWindow, self).showEvent(event)
super().showEvent(event)
if self._first_show:
self._on_first_show()
@ -343,9 +354,13 @@ class LoaderWindow(QtWidgets.QWidget):
self._show_timer.start()
def closeEvent(self, event):
super(LoaderWindow, self).closeEvent(event)
super().closeEvent(event)
self._product_types_widget.reset_product_types_filter_on_refresh()
(
self
._product_types_filter_combo
.reset_product_types_filter_on_refresh()
)
self._reset_on_show = True
@ -363,7 +378,7 @@ class LoaderWindow(QtWidgets.QWidget):
event.setAccepted(True)
return
super(LoaderWindow, self).keyPressEvent(event)
super().keyPressEvent(event)
def _on_first_show(self):
self._first_show = False
@ -423,14 +438,16 @@ class LoaderWindow(QtWidgets.QWidget):
def _on_product_filter_change(self, text):
self._products_widget.set_name_filter(text)
def _on_tasks_selection_change(self, event):
self._products_widget.set_tasks_filter(event["task_ids"])
def _on_status_filter_change(self):
status_names = self._product_status_filter_combo.get_value()
self._products_widget.set_statuses_filter(status_names)
def _on_product_type_filter_change(self):
self._products_widget.set_product_type_filter(
self._product_types_widget.get_filter_info()
)
product_types = self._product_types_filter_combo.get_value()
self._products_widget.set_product_type_filter(product_types)
def _on_merged_products_selection_change(self):
items = self._products_widget.get_selected_merged_products()

View file

@ -85,6 +85,8 @@ class AttributesWidget(QtWidgets.QWidget):
layout.setContentsMargins(0, 0, 0, 0)
layout.setHorizontalSpacing(INPUTS_LAYOUT_HSPACING)
layout.setVerticalSpacing(INPUTS_LAYOUT_VSPACING)
layout.setColumnStretch(0, 0)
layout.setColumnStretch(1, 1)
self._layout = layout

View file

@ -1,13 +0,0 @@
from .version import version, version_info, __version__
# This must be run prior to importing the application, due to the
# application requiring a discovered copy of Qt bindings.
from .app import show
__all__ = [
'show',
'version',
'version_info',
'__version__'
]

View file

@ -1,19 +0,0 @@
from .app import show
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--debug", action="store_true")
args = parser.parse_args()
if args.debug:
from . import mock
import pyblish.api
for Plugin in mock.plugins:
pyblish.api.register_plugin(Plugin)
show()

View file

@ -1,539 +0,0 @@
/* Global CSS */
* {
outline: none;
color: #ddd;
font-family: "Open Sans";
font-style: normal;
}
/* General CSS */
QWidget {
background: #555;
background-position: center center;
background-repeat: no-repeat;
font-size: 12px;
}
QMenu {
background-color: #555; /* sets background of the menu */
border: 1px solid #222;
}
QMenu::item {
/* sets background of menu item. set this to something non-transparent
if you want menu color and menu item color to be different */
background-color: transparent;
padding: 5px;
padding-left: 30px;
}
QMenu::item:selected { /* when user selects item using mouse or keyboard */
background-color: #666;
}
QDialog {
min-width: 300;
background: "#555";
}
QListView {
border: 0px;
background: "transparent"
}
QTreeView {
border: 0px;
background: "transparent"
}
QPushButton {
width: 27px;
height: 27px;
background: #555;
border: 1px solid #aaa;
border-radius: 4px;
font-family: "FontAwesome";
font-size: 11pt;
color: white;
padding: 0px;
}
QPushButton:pressed {
background: "#777";
}
QPushButton:hover {
color: white;
background: "#666";
}
QPushButton:disabled {
color: rgba(255, 255, 255, 50);
}
QTextEdit, QLineEdit {
background: #555;
border: 1px solid #333;
font-size: 9pt;
color: #fff;
}
QCheckBox {
min-width: 17px;
max-width: 17px;
border: 1px solid #222;
background: transparent;
}
QCheckBox::indicator {
width: 15px;
height: 15px;
/*background: #444;*/
background: transparent;
border: 1px solid #555;
}
QCheckBox::indicator:checked {
background: #222;
}
QComboBox {
background: #444;
color: #EEE;
font-size: 8pt;
border: 1px solid #333;
padding: 0px;
}
QComboBox[combolist="true"]::drop-down {
background: transparent;
}
QComboBox[combolist="true"]::down-arrow {
max-width: 0px;
width: 1px;
}
QComboBox[combolist="true"] QAbstractItemView {
background: #555;
}
QScrollBar:vertical {
border: none;
background: transparent;
width: 6px;
margin: 0;
}
QScrollBar::handle:vertical {
background: #333;
border-radius: 3px;
min-height: 20px;
}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {
height: 0px;
}
QScrollBar::up-arrow:vertical, QScrollBar::down-arrow:vertical {
border: 1px solid #444;
width: 3px;
height: 3px;
background: white;
}
QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {
background: none;
}
QToolTip {
color: #eee;
background-color: #555;
border: none;
padding: 5px;
}
QLabel {
border-radius: 0px;
}
QToolButton {
background-color: transparent;
margin: 0px;
padding: 0px;
border-radius: 0px;
border: none;
}
/* Specific CSS */
#PerspectiveToggleBtn {
border-bottom: 3px solid lightblue;
border-top: 0px;
border-radius: 0px;
border-right: 1px solid #232323;
border-left: 0px;
font-size: 26pt;
font-family: "FontAwesome";
}
#Terminal QComboBox::drop-down {
width: 60px;
}
#Header {
background: #555;
border: 1px solid #444;
padding: 0px;
margin: 0px;
}
#Header QRadioButton {
border: 3px solid "transparent";
border-right: 1px solid #333;
left: 2px;
}
#Header QRadioButton::indicator {
width: 65px;
height: 40px;
background-repeat: no-repeat;
background-position: center center;
image: none;
}
#Header QRadioButton:hover {
background-color: rgba(255, 255, 255, 10);
}
#Header QRadioButton:checked {
background-color: rgba(255, 255, 255, 20);
border-bottom: 3px solid "lightblue";
}
#Body {
padding: 0px;
border: 1px solid #333;
background: #444;
}
#Body QWidget {
background: #444;
}
#Header #TerminalTab {
background-image: url("img/tab-terminal.png");
}
#Header #OverviewTab {
background-image: url("img/tab-overview.png");
}
#ButtonWithMenu {
background: #555;
border: 1px solid #fff;
border-radius: 4px;
font-family: "FontAwesome";
font-size: 11pt;
color: white;
}
#ButtonWithMenu:pressed {
background: #777;
}
#ButtonWithMenu:hover {
color: white;
background: #666;
}
#ButtonWithMenu:disabled {
background: #666;
color: #999;
border: 1px solid #999;
}
#FooterSpacer, #FooterInfo, #HeaderSpacer {
background: transparent;
}
#Footer {
background: #555;
min-height: 43px;
}
#Footer[success="1"] {
background: #458056
}
#Footer[success="0"] {
background-color: #AA5050
}
#Footer QPushButton {
background: #555;
border: 1px solid #aaa;
border-radius: 4px;
font-family: "FontAwesome";
font-size: 11pt;
color: white;
padding: 0px;
}
#Footer QPushButton:pressed:hover {
color: #3784c5;
background: #444;
}
#Footer QPushButton:hover {
background: #505050;
border: 2px solid #3784c5;
}
#Footer QPushButton:disabled {
border: 1px solid #888;
background: #666;
color: #999;
}
#ClosingPlaceholder {
background: rgba(0, 0, 0, 50);
}
#CommentIntentWidget {
background: transparent;
}
#CommentBox, #CommentPlaceholder {
font-family: "Open Sans";
font-size: 8pt;
padding: 5px;
background: #444;
}
#CommentBox {
selection-background-color: #222;
}
#CommentBox:disabled, #CommentPlaceholder:disabled, #IntentBox:disabled {
background: #555;
}
#CommentPlaceholder {
color: #888
}
#IntentBox {
background: #444;
font-size: 8pt;
padding: 5px;
min-width: 75px;
color: #EEE;
}
#IntentBox::drop-down:button {
border: 0px;
background: transparent;
}
#IntentBox::down-arrow {
image: url("/img/down_arrow.png");
}
#IntentBox::down-arrow:disabled {
image: url();
}
#TerminalView {
background-color: transparent;
}
#TerminalView:item {
background-color: transparent;
}
#TerminalView:hover {
background-color: transparent;
}
#TerminalView:selected {
background-color: transparent;
}
#TerminalView:item:hover {
color: #ffffff;
}
#TerminalView:item:selected {
color: #eeeeee;
}
#TerminalView QTextEdit {
padding:3px;
color: #aaa;
border-radius: 7px;
border-color: #222;
border-style: solid;
border-width: 2px;
background-color: #333;
}
#TerminalView QTextEdit:hover {
background-color: #353535;
}
#TerminalView QTextEdit:selected {
background-color: #303030;
}
#ExpandableWidgetContent {
border: none;
background-color: #232323;
color:#eeeeee;
}
#EllidableLabel {
font-size: 16pt;
font-weight: normal;
}
#PerspectiveScrollContent {
border: 1px solid #333;
border-radius: 0px;
}
#PerspectiveWidgetContent{
padding: 0px;
}
#PerspectiveLabel {
background-color: transparent;
border: none;
}
#PerspectiveIndicator {
font-size: 16pt;
font-weight: normal;
padding: 5px;
background-color: #ffffff;
color: #333333;
}
#PerspectiveIndicator[state="warning"] {
background-color: #ff9900;
color: #ffffff;
}
#PerspectiveIndicator[state="active"] {
background-color: #99CEEE;
color: #ffffff;
}
#PerspectiveIndicator[state="error"] {
background-color: #cc4a4a;
color: #ffffff;
}
#PerspectiveIndicator[state="ok"] {
background-color: #69a567;
color: #ffffff;
}
#ExpandableHeader {
background-color: transparent;
margin: 0px;
padding: 0px;
border-radius: 0px;
border: none;
}
#ExpandableHeader QWidget {
color: #ddd;
}
#ExpandableHeader QWidget:hover {
color: #fff;
}
#TerminalFilterWidget QPushButton {
/* font: %(font_size_pt)spt; */
font-family: "FontAwesome";
text-align: center;
background-color: transparent;
border-width: 1px;
border-color: #777777;
border-style: none;
padding: 0px;
border-radius: 8px;
}
#TerminalFilterWidget QPushButton:hover {
background: #5f5f5f;
border-style: none;
}
#TerminalFilterWidget QPushButton:pressed {
background: #606060;
border-style: none;
}
#TerminalFilterWidget QPushButton:pressed:hover {
background: #626262;
border-style: none;
}
#TerminalFilerBtn[type="info"]:checked {color: rgb(255, 255, 255);}
#TerminalFilerBtn[type="info"]:hover:pressed {color: rgba(255, 255, 255, 163);}
#TerminalFilerBtn[type="info"] {color: rgba(255, 255, 255, 63);}
#TerminalFilerBtn[type="error"]:checked {color: rgb(255, 74, 74);}
#TerminalFilerBtn[type="error"]:hover:pressed {color: rgba(255, 74, 74, 163);}
#TerminalFilerBtn[type="error"] {color: rgba(255, 74, 74, 63);}
#TerminalFilerBtn[type="log_debug"]:checked {color: rgb(255, 102, 232);}
#TerminalFilerBtn[type="log_debug"] {color: rgba(255, 102, 232, 63);}
#TerminalFilerBtn[type="log_debug"]:hover:pressed {
color: rgba(255, 102, 232, 163);
}
#TerminalFilerBtn[type="log_info"]:checked {color: rgb(102, 171, 255);}
#TerminalFilerBtn[type="log_info"] {color: rgba(102, 171, 255, 63);}
#TerminalFilerBtn[type="log_info"]:hover:pressed {
color: rgba(102, 171, 255, 163);
}
#TerminalFilerBtn[type="log_warning"]:checked {color: rgb(255, 186, 102);}
#TerminalFilerBtn[type="log_warning"] {color: rgba(255, 186, 102, 63);}
#TerminalFilerBtn[type="log_warning"]:hover:pressed {
color: rgba(255, 186, 102, 163);
}
#TerminalFilerBtn[type="log_error"]:checked {color: rgb(255, 77, 88);}
#TerminalFilerBtn[type="log_error"] {color: rgba(255, 77, 88, 63);}
#TerminalFilerBtn[type="log_error"]:hover:pressed {
color: rgba(255, 77, 88, 163);
}
#TerminalFilerBtn[type="log_critical"]:checked {color: rgb(255, 79, 117);}
#TerminalFilerBtn[type="log_critical"] {color: rgba(255, 79, 117, 63);}
#TerminalFilerBtn[type="log_critical"]:hover:pressed {
color: rgba(255, 79, 117, 163);
}
#SuspendLogsBtn {
background: #444;
border: none;
border-top-right-radius: 7px;
border-bottom-right-radius: 7px;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
font-family: "FontAwesome";
font-size: 11pt;
color: white;
padding: 0px;
}
#SuspendLogsBtn:hover {
background: #333;
}
#SuspendLogsBtn:disabled {
background: #4c4c4c;
}

View file

@ -1,110 +0,0 @@
from __future__ import print_function
import os
import sys
import ctypes
import platform
import contextlib
from qtpy import QtCore, QtGui, QtWidgets
from . import control, settings, util, window
self = sys.modules[__name__]
# Maintain reference to currently opened window
self._window = None
@contextlib.contextmanager
def application():
app = QtWidgets.QApplication.instance()
if not app:
print("Starting new QApplication..")
app = QtWidgets.QApplication(sys.argv)
yield app
app.exec_()
else:
print("Using existing QApplication..")
yield app
if os.environ.get("PYBLISH_GUI_ALWAYS_EXEC"):
app.exec_()
def install_translator(app):
translator = QtCore.QTranslator(app)
translator.load(QtCore.QLocale.system(), "i18n/",
directory=util.root)
app.installTranslator(translator)
print("Installed translator")
def install_fonts():
database = QtGui.QFontDatabase()
fonts = [
"opensans/OpenSans-Bold.ttf",
"opensans/OpenSans-BoldItalic.ttf",
"opensans/OpenSans-ExtraBold.ttf",
"opensans/OpenSans-ExtraBoldItalic.ttf",
"opensans/OpenSans-Italic.ttf",
"opensans/OpenSans-Light.ttf",
"opensans/OpenSans-LightItalic.ttf",
"opensans/OpenSans-Regular.ttf",
"opensans/OpenSans-Semibold.ttf",
"opensans/OpenSans-SemiboldItalic.ttf",
"fontawesome/fontawesome-webfont.ttf"
]
for font in fonts:
path = util.get_asset("font", font)
# TODO(marcus): Check if they are already installed first.
# In hosts, this will be called each time the GUI is shown,
# potentially installing a font each time.
if database.addApplicationFont(path) < 0:
print("Could not install %s" % path)
else:
print("Installed %s" % font)
def on_destroyed():
"""Remove internal reference to window on window destroyed"""
self._window = None
def show(parent=None):
with open(util.get_asset("app.css")) as f:
css = f.read()
# Make relative paths absolute
root = util.get_asset("").replace("\\", "/")
css = css.replace("url(\"", "url(\"%s" % root)
with application() as app:
if platform.system().lower() == "windows":
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(
u"pyblish_pype"
)
install_fonts()
install_translator(app)
if self._window is None:
ctrl = control.Controller()
self._window = window.Window(ctrl, parent)
self._window.destroyed.connect(on_destroyed)
self._window.show()
self._window.activateWindow()
self._window.setWindowTitle(settings.WindowTitle)
font = QtGui.QFont("Open Sans", 8, QtGui.QFont.Normal)
self._window.setFont(font)
self._window.setStyleSheet(css)
self._window.reset()
self._window.resize(*settings.WindowSize)
return self._window

View file

@ -1,733 +0,0 @@
tags = {
"500px": u"\uf26e",
"adjust": u"\uf042",
"adn": u"\uf170",
"align-center": u"\uf037",
"align-justify": u"\uf039",
"align-left": u"\uf036",
"align-right": u"\uf038",
"amazon": u"\uf270",
"ambulance": u"\uf0f9",
"american-sign-language-interpreting": u"\uf2a3",
"anchor": u"\uf13d",
"android": u"\uf17b",
"angellist": u"\uf209",
"angle-double-down": u"\uf103",
"angle-double-left": u"\uf100",
"angle-double-right": u"\uf101",
"angle-double-up": u"\uf102",
"angle-down": u"\uf107",
"angle-left": u"\uf104",
"angle-right": u"\uf105",
"angle-up": u"\uf106",
"apple": u"\uf179",
"archive": u"\uf187",
"area-chart": u"\uf1fe",
"arrow-circle-down": u"\uf0ab",
"arrow-circle-left": u"\uf0a8",
"arrow-circle-o-down": u"\uf01a",
"arrow-circle-o-left": u"\uf190",
"arrow-circle-o-right": u"\uf18e",
"arrow-circle-o-up": u"\uf01b",
"arrow-circle-right": u"\uf0a9",
"arrow-circle-up": u"\uf0aa",
"arrow-down": u"\uf063",
"arrow-left": u"\uf060",
"arrow-right": u"\uf061",
"arrow-up": u"\uf062",
"arrows": u"\uf047",
"arrows-alt": u"\uf0b2",
"arrows-h": u"\uf07e",
"arrows-v": u"\uf07d",
"asl-interpreting (alias)": u"\uf2a3",
"assistive-listening-systems": u"\uf2a2",
"asterisk": u"\uf069",
"at": u"\uf1fa",
"audio-description": u"\uf29e",
"automobile (alias)": u"\uf1b9",
"backward": u"\uf04a",
"balance-scale": u"\uf24e",
"ban": u"\uf05e",
"bank (alias)": u"\uf19c",
"bar-chart": u"\uf080",
"bar-chart-o (alias)": u"\uf080",
"barcode": u"\uf02a",
"bars": u"\uf0c9",
"battery-0 (alias)": u"\uf244",
"battery-1 (alias)": u"\uf243",
"battery-2 (alias)": u"\uf242",
"battery-3 (alias)": u"\uf241",
"battery-4 (alias)": u"\uf240",
"battery-empty": u"\uf244",
"battery-full": u"\uf240",
"battery-half": u"\uf242",
"battery-quarter": u"\uf243",
"battery-three-quarters": u"\uf241",
"bed": u"\uf236",
"beer": u"\uf0fc",
"behance": u"\uf1b4",
"behance-square": u"\uf1b5",
"bell": u"\uf0f3",
"bell-o": u"\uf0a2",
"bell-slash": u"\uf1f6",
"bell-slash-o": u"\uf1f7",
"bicycle": u"\uf206",
"binoculars": u"\uf1e5",
"birthday-cake": u"\uf1fd",
"bitbucket": u"\uf171",
"bitbucket-square": u"\uf172",
"bitcoin (alias)": u"\uf15a",
"black-tie": u"\uf27e",
"blind": u"\uf29d",
"bluetooth": u"\uf293",
"bluetooth-b": u"\uf294",
"bold": u"\uf032",
"bolt": u"\uf0e7",
"bomb": u"\uf1e2",
"book": u"\uf02d",
"bookmark": u"\uf02e",
"bookmark-o": u"\uf097",
"braille": u"\uf2a1",
"briefcase": u"\uf0b1",
"btc": u"\uf15a",
"bug": u"\uf188",
"building": u"\uf1ad",
"building-o": u"\uf0f7",
"bullhorn": u"\uf0a1",
"bullseye": u"\uf140",
"bus": u"\uf207",
"buysellads": u"\uf20d",
"cab (alias)": u"\uf1ba",
"calculator": u"\uf1ec",
"calendar": u"\uf073",
"calendar-check-o": u"\uf274",
"calendar-minus-o": u"\uf272",
"calendar-o": u"\uf133",
"calendar-plus-o": u"\uf271",
"calendar-times-o": u"\uf273",
"camera": u"\uf030",
"camera-retro": u"\uf083",
"car": u"\uf1b9",
"caret-down": u"\uf0d7",
"caret-left": u"\uf0d9",
"caret-right": u"\uf0da",
"caret-square-o-down": u"\uf150",
"caret-square-o-left": u"\uf191",
"caret-square-o-right": u"\uf152",
"caret-square-o-up": u"\uf151",
"caret-up": u"\uf0d8",
"cart-arrow-down": u"\uf218",
"cart-plus": u"\uf217",
"cc": u"\uf20a",
"cc-amex": u"\uf1f3",
"cc-diners-club": u"\uf24c",
"cc-discover": u"\uf1f2",
"cc-jcb": u"\uf24b",
"cc-mastercard": u"\uf1f1",
"cc-paypal": u"\uf1f4",
"cc-stripe": u"\uf1f5",
"cc-visa": u"\uf1f0",
"certificate": u"\uf0a3",
"chain (alias)": u"\uf0c1",
"chain-broken": u"\uf127",
"check": u"\uf00c",
"check-circle": u"\uf058",
"check-circle-o": u"\uf05d",
"check-square": u"\uf14a",
"check-square-o": u"\uf046",
"chevron-circle-down": u"\uf13a",
"chevron-circle-left": u"\uf137",
"chevron-circle-right": u"\uf138",
"chevron-circle-up": u"\uf139",
"chevron-down": u"\uf078",
"chevron-left": u"\uf053",
"chevron-right": u"\uf054",
"chevron-up": u"\uf077",
"child": u"\uf1ae",
"chrome": u"\uf268",
"circle": u"\uf111",
"circle-o": u"\uf10c",
"circle-o-notch": u"\uf1ce",
"circle-thin": u"\uf1db",
"clipboard": u"\uf0ea",
"clock-o": u"\uf017",
"clone": u"\uf24d",
"close (alias)": u"\uf00d",
"cloud": u"\uf0c2",
"cloud-download": u"\uf0ed",
"cloud-upload": u"\uf0ee",
"cny (alias)": u"\uf157",
"code": u"\uf121",
"code-fork": u"\uf126",
"codepen": u"\uf1cb",
"codiepie": u"\uf284",
"coffee": u"\uf0f4",
"cog": u"\uf013",
"cogs": u"\uf085",
"columns": u"\uf0db",
"comment": u"\uf075",
"comment-o": u"\uf0e5",
"commenting": u"\uf27a",
"commenting-o": u"\uf27b",
"comments": u"\uf086",
"comments-o": u"\uf0e6",
"compass": u"\uf14e",
"compress": u"\uf066",
"connectdevelop": u"\uf20e",
"contao": u"\uf26d",
"copy (alias)": u"\uf0c5",
"copyright": u"\uf1f9",
"creative-commons": u"\uf25e",
"credit-card": u"\uf09d",
"credit-card-alt": u"\uf283",
"crop": u"\uf125",
"crosshairs": u"\uf05b",
"css3": u"\uf13c",
"cube": u"\uf1b2",
"cubes": u"\uf1b3",
"cut (alias)": u"\uf0c4",
"cutlery": u"\uf0f5",
"dashboard (alias)": u"\uf0e4",
"dashcube": u"\uf210",
"database": u"\uf1c0",
"deaf": u"\uf2a4",
"deafness (alias)": u"\uf2a4",
"dedent (alias)": u"\uf03b",
"delicious": u"\uf1a5",
"desktop": u"\uf108",
"deviantart": u"\uf1bd",
"diamond": u"\uf219",
"digg": u"\uf1a6",
"dollar (alias)": u"\uf155",
"dot-circle-o": u"\uf192",
"download": u"\uf019",
"dribbble": u"\uf17d",
"dropbox": u"\uf16b",
"drupal": u"\uf1a9",
"edge": u"\uf282",
"edit (alias)": u"\uf044",
"eject": u"\uf052",
"ellipsis-h": u"\uf141",
"ellipsis-v": u"\uf142",
"empire": u"\uf1d1",
"envelope": u"\uf0e0",
"envelope-o": u"\uf003",
"envelope-square": u"\uf199",
"envira": u"\uf299",
"eraser": u"\uf12d",
"eur": u"\uf153",
"euro (alias)": u"\uf153",
"exchange": u"\uf0ec",
"exclamation": u"\uf12a",
"exclamation-circle": u"\uf06a",
"exclamation-triangle": u"\uf071",
"expand": u"\uf065",
"expeditedssl": u"\uf23e",
"external-link": u"\uf08e",
"external-link-square": u"\uf14c",
"eye": u"\uf06e",
"eye-slash": u"\uf070",
"eyedropper": u"\uf1fb",
"fa (alias)": u"\uf2b4",
"facebook": u"\uf09a",
"facebook-f (alias)": u"\uf09a",
"facebook-official": u"\uf230",
"facebook-square": u"\uf082",
"fast-backward": u"\uf049",
"fast-forward": u"\uf050",
"fax": u"\uf1ac",
"feed (alias)": u"\uf09e",
"female": u"\uf182",
"fighter-jet": u"\uf0fb",
"file": u"\uf15b",
"file-archive-o": u"\uf1c6",
"file-audio-o": u"\uf1c7",
"file-code-o": u"\uf1c9",
"file-excel-o": u"\uf1c3",
"file-image-o": u"\uf1c5",
"file-movie-o (alias)": u"\uf1c8",
"file-o": u"\uf016",
"file-pdf-o": u"\uf1c1",
"file-photo-o (alias)": u"\uf1c5",
"file-picture-o (alias)": u"\uf1c5",
"file-powerpoint-o": u"\uf1c4",
"file-sound-o (alias)": u"\uf1c7",
"file-text": u"\uf15c",
"file-text-o": u"\uf0f6",
"file-video-o": u"\uf1c8",
"file-word-o": u"\uf1c2",
"file-zip-o (alias)": u"\uf1c6",
"files-o": u"\uf0c5",
"film": u"\uf008",
"filter": u"\uf0b0",
"fire": u"\uf06d",
"fire-extinguisher": u"\uf134",
"firefox": u"\uf269",
"first-order": u"\uf2b0",
"flag": u"\uf024",
"flag-checkered": u"\uf11e",
"flag-o": u"\uf11d",
"flash (alias)": u"\uf0e7",
"flask": u"\uf0c3",
"flickr": u"\uf16e",
"floppy-o": u"\uf0c7",
"folder": u"\uf07b",
"folder-o": u"\uf114",
"folder-open": u"\uf07c",
"folder-open-o": u"\uf115",
"font": u"\uf031",
"font-awesome": u"\uf2b4",
"fonticons": u"\uf280",
"fort-awesome": u"\uf286",
"forumbee": u"\uf211",
"forward": u"\uf04e",
"foursquare": u"\uf180",
"frown-o": u"\uf119",
"futbol-o": u"\uf1e3",
"gamepad": u"\uf11b",
"gavel": u"\uf0e3",
"gbp": u"\uf154",
"ge (alias)": u"\uf1d1",
"gear (alias)": u"\uf013",
"gears (alias)": u"\uf085",
"genderless": u"\uf22d",
"get-pocket": u"\uf265",
"gg": u"\uf260",
"gg-circle": u"\uf261",
"gift": u"\uf06b",
"git": u"\uf1d3",
"git-square": u"\uf1d2",
"github": u"\uf09b",
"github-alt": u"\uf113",
"github-square": u"\uf092",
"gitlab": u"\uf296",
"gittip (alias)": u"\uf184",
"glass": u"\uf000",
"glide": u"\uf2a5",
"glide-g": u"\uf2a6",
"globe": u"\uf0ac",
"google": u"\uf1a0",
"google-plus": u"\uf0d5",
"google-plus-circle (alias)": u"\uf2b3",
"google-plus-official": u"\uf2b3",
"google-plus-square": u"\uf0d4",
"google-wallet": u"\uf1ee",
"graduation-cap": u"\uf19d",
"gratipay": u"\uf184",
"group (alias)": u"\uf0c0",
"h-square": u"\uf0fd",
"hacker-news": u"\uf1d4",
"hand-grab-o (alias)": u"\uf255",
"hand-lizard-o": u"\uf258",
"hand-o-down": u"\uf0a7",
"hand-o-left": u"\uf0a5",
"hand-o-right": u"\uf0a4",
"hand-o-up": u"\uf0a6",
"hand-paper-o": u"\uf256",
"hand-peace-o": u"\uf25b",
"hand-pointer-o": u"\uf25a",
"hand-rock-o": u"\uf255",
"hand-scissors-o": u"\uf257",
"hand-spock-o": u"\uf259",
"hand-stop-o (alias)": u"\uf256",
"hard-of-hearing (alias)": u"\uf2a4",
"hashtag": u"\uf292",
"hdd-o": u"\uf0a0",
"header": u"\uf1dc",
"headphones": u"\uf025",
"heart": u"\uf004",
"heart-o": u"\uf08a",
"heartbeat": u"\uf21e",
"history": u"\uf1da",
"home": u"\uf015",
"hospital-o": u"\uf0f8",
"hotel (alias)": u"\uf236",
"hourglass": u"\uf254",
"hourglass-1 (alias)": u"\uf251",
"hourglass-2 (alias)": u"\uf252",
"hourglass-3 (alias)": u"\uf253",
"hourglass-end": u"\uf253",
"hourglass-half": u"\uf252",
"hourglass-o": u"\uf250",
"hourglass-start": u"\uf251",
"houzz": u"\uf27c",
"html5": u"\uf13b",
"i-cursor": u"\uf246",
"ils": u"\uf20b",
"image (alias)": u"\uf03e",
"inbox": u"\uf01c",
"indent": u"\uf03c",
"industry": u"\uf275",
"info": u"\uf129",
"info-circle": u"\uf05a",
"inr": u"\uf156",
"instagram": u"\uf16d",
"institution (alias)": u"\uf19c",
"internet-explorer": u"\uf26b",
"intersex (alias)": u"\uf224",
"ioxhost": u"\uf208",
"italic": u"\uf033",
"joomla": u"\uf1aa",
"jpy": u"\uf157",
"jsfiddle": u"\uf1cc",
"key": u"\uf084",
"keyboard-o": u"\uf11c",
"krw": u"\uf159",
"language": u"\uf1ab",
"laptop": u"\uf109",
"lastfm": u"\uf202",
"lastfm-square": u"\uf203",
"leaf": u"\uf06c",
"leanpub": u"\uf212",
"legal (alias)": u"\uf0e3",
"lemon-o": u"\uf094",
"level-down": u"\uf149",
"level-up": u"\uf148",
"life-bouy (alias)": u"\uf1cd",
"life-buoy (alias)": u"\uf1cd",
"life-ring": u"\uf1cd",
"life-saver (alias)": u"\uf1cd",
"lightbulb-o": u"\uf0eb",
"line-chart": u"\uf201",
"link": u"\uf0c1",
"linkedin": u"\uf0e1",
"linkedin-square": u"\uf08c",
"linux": u"\uf17c",
"list": u"\uf03a",
"list-alt": u"\uf022",
"list-ol": u"\uf0cb",
"list-ul": u"\uf0ca",
"location-arrow": u"\uf124",
"lock": u"\uf023",
"long-arrow-down": u"\uf175",
"long-arrow-left": u"\uf177",
"long-arrow-right": u"\uf178",
"long-arrow-up": u"\uf176",
"low-vision": u"\uf2a8",
"magic": u"\uf0d0",
"magnet": u"\uf076",
"mail-forward (alias)": u"\uf064",
"mail-reply (alias)": u"\uf112",
"mail-reply-all (alias)": u"\uf122",
"male": u"\uf183",
"map": u"\uf279",
"map-marker": u"\uf041",
"map-o": u"\uf278",
"map-pin": u"\uf276",
"map-signs": u"\uf277",
"mars": u"\uf222",
"mars-double": u"\uf227",
"mars-stroke": u"\uf229",
"mars-stroke-h": u"\uf22b",
"mars-stroke-v": u"\uf22a",
"maxcdn": u"\uf136",
"meanpath": u"\uf20c",
"medium": u"\uf23a",
"medkit": u"\uf0fa",
"meh-o": u"\uf11a",
"mercury": u"\uf223",
"microphone": u"\uf130",
"microphone-slash": u"\uf131",
"minus": u"\uf068",
"minus-circle": u"\uf056",
"minus-square": u"\uf146",
"minus-square-o": u"\uf147",
"mixcloud": u"\uf289",
"mobile": u"\uf10b",
"mobile-phone (alias)": u"\uf10b",
"modx": u"\uf285",
"money": u"\uf0d6",
"moon-o": u"\uf186",
"mortar-board (alias)": u"\uf19d",
"motorcycle": u"\uf21c",
"mouse-pointer": u"\uf245",
"music": u"\uf001",
"navicon (alias)": u"\uf0c9",
"neuter": u"\uf22c",
"newspaper-o": u"\uf1ea",
"object-group": u"\uf247",
"object-ungroup": u"\uf248",
"odnoklassniki": u"\uf263",
"odnoklassniki-square": u"\uf264",
"opencart": u"\uf23d",
"openid": u"\uf19b",
"opera": u"\uf26a",
"optin-monster": u"\uf23c",
"outdent": u"\uf03b",
"pagelines": u"\uf18c",
"paint-brush": u"\uf1fc",
"paper-plane": u"\uf1d8",
"paper-plane-o": u"\uf1d9",
"paperclip": u"\uf0c6",
"paragraph": u"\uf1dd",
"paste (alias)": u"\uf0ea",
"pause": u"\uf04c",
"pause-circle": u"\uf28b",
"pause-circle-o": u"\uf28c",
"paw": u"\uf1b0",
"paypal": u"\uf1ed",
"pencil": u"\uf040",
"pencil-square": u"\uf14b",
"pencil-square-o": u"\uf044",
"percent": u"\uf295",
"phone": u"\uf095",
"phone-square": u"\uf098",
"photo (alias)": u"\uf03e",
"picture-o": u"\uf03e",
"pie-chart": u"\uf200",
"pied-piper": u"\uf2ae",
"pied-piper-alt": u"\uf1a8",
"pied-piper-pp": u"\uf1a7",
"pinterest": u"\uf0d2",
"pinterest-p": u"\uf231",
"pinterest-square": u"\uf0d3",
"plane": u"\uf072",
"play": u"\uf04b",
"play-circle": u"\uf144",
"play-circle-o": u"\uf01d",
"plug": u"\uf1e6",
"plus": u"\uf067",
"plus-circle": u"\uf055",
"plus-square": u"\uf0fe",
"plus-square-o": u"\uf196",
"power-off": u"\uf011",
"print": u"\uf02f",
"product-hunt": u"\uf288",
"puzzle-piece": u"\uf12e",
"qq": u"\uf1d6",
"qrcode": u"\uf029",
"question": u"\uf128",
"question-circle": u"\uf059",
"question-circle-o": u"\uf29c",
"quote-left": u"\uf10d",
"quote-right": u"\uf10e",
"ra (alias)": u"\uf1d0",
"random": u"\uf074",
"rebel": u"\uf1d0",
"recycle": u"\uf1b8",
"reddit": u"\uf1a1",
"reddit-alien": u"\uf281",
"reddit-square": u"\uf1a2",
"refresh": u"\uf021",
"registered": u"\uf25d",
"remove (alias)": u"\uf00d",
"renren": u"\uf18b",
"reorder (alias)": u"\uf0c9",
"repeat": u"\uf01e",
"reply": u"\uf112",
"reply-all": u"\uf122",
"resistance (alias)": u"\uf1d0",
"retweet": u"\uf079",
"rmb (alias)": u"\uf157",
"road": u"\uf018",
"rocket": u"\uf135",
"rotate-left (alias)": u"\uf0e2",
"rotate-right (alias)": u"\uf01e",
"rouble (alias)": u"\uf158",
"rss": u"\uf09e",
"rss-square": u"\uf143",
"rub": u"\uf158",
"ruble (alias)": u"\uf158",
"rupee (alias)": u"\uf156",
"safari": u"\uf267",
"save (alias)": u"\uf0c7",
"scissors": u"\uf0c4",
"scribd": u"\uf28a",
"search": u"\uf002",
"search-minus": u"\uf010",
"search-plus": u"\uf00e",
"sellsy": u"\uf213",
"send (alias)": u"\uf1d8",
"send-o (alias)": u"\uf1d9",
"server": u"\uf233",
"share": u"\uf064",
"share-alt": u"\uf1e0",
"share-alt-square": u"\uf1e1",
"share-square": u"\uf14d",
"share-square-o": u"\uf045",
"shekel (alias)": u"\uf20b",
"sheqel (alias)": u"\uf20b",
"shield": u"\uf132",
"ship": u"\uf21a",
"shirtsinbulk": u"\uf214",
"shopping-bag": u"\uf290",
"shopping-basket": u"\uf291",
"shopping-cart": u"\uf07a",
"sign-in": u"\uf090",
"sign-language": u"\uf2a7",
"sign-out": u"\uf08b",
"signal": u"\uf012",
"signing (alias)": u"\uf2a7",
"simplybuilt": u"\uf215",
"sitemap": u"\uf0e8",
"skyatlas": u"\uf216",
"skype": u"\uf17e",
"slack": u"\uf198",
"sliders": u"\uf1de",
"slideshare": u"\uf1e7",
"smile-o": u"\uf118",
"snapchat": u"\uf2ab",
"snapchat-ghost": u"\uf2ac",
"snapchat-square": u"\uf2ad",
"soccer-ball-o (alias)": u"\uf1e3",
"sort": u"\uf0dc",
"sort-alpha-asc": u"\uf15d",
"sort-alpha-desc": u"\uf15e",
"sort-amount-asc": u"\uf160",
"sort-amount-desc": u"\uf161",
"sort-asc": u"\uf0de",
"sort-desc": u"\uf0dd",
"sort-down (alias)": u"\uf0dd",
"sort-numeric-asc": u"\uf162",
"sort-numeric-desc": u"\uf163",
"sort-up (alias)": u"\uf0de",
"soundcloud": u"\uf1be",
"space-shuttle": u"\uf197",
"spinner": u"\uf110",
"spoon": u"\uf1b1",
"spotify": u"\uf1bc",
"square": u"\uf0c8",
"square-o": u"\uf096",
"stack-exchange": u"\uf18d",
"stack-overflow": u"\uf16c",
"star": u"\uf005",
"star-half": u"\uf089",
"star-half-empty (alias)": u"\uf123",
"star-half-full (alias)": u"\uf123",
"star-half-o": u"\uf123",
"star-o": u"\uf006",
"steam": u"\uf1b6",
"steam-square": u"\uf1b7",
"step-backward": u"\uf048",
"step-forward": u"\uf051",
"stethoscope": u"\uf0f1",
"sticky-note": u"\uf249",
"sticky-note-o": u"\uf24a",
"stop": u"\uf04d",
"stop-circle": u"\uf28d",
"stop-circle-o": u"\uf28e",
"street-view": u"\uf21d",
"strikethrough": u"\uf0cc",
"stumbleupon": u"\uf1a4",
"stumbleupon-circle": u"\uf1a3",
"subscript": u"\uf12c",
"subway": u"\uf239",
"suitcase": u"\uf0f2",
"sun-o": u"\uf185",
"superscript": u"\uf12b",
"support (alias)": u"\uf1cd",
"table": u"\uf0ce",
"tablet": u"\uf10a",
"tachometer": u"\uf0e4",
"tag": u"\uf02b",
"tags": u"\uf02c",
"tasks": u"\uf0ae",
"taxi": u"\uf1ba",
"television": u"\uf26c",
"tencent-weibo": u"\uf1d5",
"terminal": u"\uf120",
"text-height": u"\uf034",
"text-width": u"\uf035",
"th": u"\uf00a",
"th-large": u"\uf009",
"th-list": u"\uf00b",
"themeisle": u"\uf2b2",
"thumb-tack": u"\uf08d",
"thumbs-down": u"\uf165",
"thumbs-o-down": u"\uf088",
"thumbs-o-up": u"\uf087",
"thumbs-up": u"\uf164",
"ticket": u"\uf145",
"times": u"\uf00d",
"times-circle": u"\uf057",
"times-circle-o": u"\uf05c",
"tint": u"\uf043",
"toggle-down (alias)": u"\uf150",
"toggle-left (alias)": u"\uf191",
"toggle-off": u"\uf204",
"toggle-on": u"\uf205",
"toggle-right (alias)": u"\uf152",
"toggle-up (alias)": u"\uf151",
"trademark": u"\uf25c",
"train": u"\uf238",
"transgender": u"\uf224",
"transgender-alt": u"\uf225",
"trash": u"\uf1f8",
"trash-o": u"\uf014",
"tree": u"\uf1bb",
"trello": u"\uf181",
"tripadvisor": u"\uf262",
"trophy": u"\uf091",
"truck": u"\uf0d1",
"try": u"\uf195",
"tty": u"\uf1e4",
"tumblr": u"\uf173",
"tumblr-square": u"\uf174",
"turkish-lira (alias)": u"\uf195",
"tv (alias)": u"\uf26c",
"twitch": u"\uf1e8",
"twitter": u"\uf099",
"twitter-square": u"\uf081",
"umbrella": u"\uf0e9",
"underline": u"\uf0cd",
"undo": u"\uf0e2",
"universal-access": u"\uf29a",
"university": u"\uf19c",
"unlink (alias)": u"\uf127",
"unlock": u"\uf09c",
"unlock-alt": u"\uf13e",
"unsorted (alias)": u"\uf0dc",
"upload": u"\uf093",
"usb": u"\uf287",
"usd": u"\uf155",
"user": u"\uf007",
"user-md": u"\uf0f0",
"user-plus": u"\uf234",
"user-secret": u"\uf21b",
"user-times": u"\uf235",
"users": u"\uf0c0",
"venus": u"\uf221",
"venus-double": u"\uf226",
"venus-mars": u"\uf228",
"viacoin": u"\uf237",
"viadeo": u"\uf2a9",
"viadeo-square": u"\uf2aa",
"video-camera": u"\uf03d",
"vimeo": u"\uf27d",
"vimeo-square": u"\uf194",
"vine": u"\uf1ca",
"vk": u"\uf189",
"volume-control-phone": u"\uf2a0",
"volume-down": u"\uf027",
"volume-off": u"\uf026",
"volume-up": u"\uf028",
"warning (alias)": u"\uf071",
"wechat (alias)": u"\uf1d7",
"weibo": u"\uf18a",
"weixin": u"\uf1d7",
"whatsapp": u"\uf232",
"wheelchair": u"\uf193",
"wheelchair-alt": u"\uf29b",
"wifi": u"\uf1eb",
"wikipedia-w": u"\uf266",
"windows": u"\uf17a",
"won (alias)": u"\uf159",
"wordpress": u"\uf19a",
"wpbeginner": u"\uf297",
"wpforms": u"\uf298",
"wrench": u"\uf0ad",
"xing": u"\uf168",
"xing-square": u"\uf169",
"y-combinator": u"\uf23b",
"y-combinator-square (alias)": u"\uf1d4",
"yahoo": u"\uf19e",
"yc (alias)": u"\uf23b",
"yc-square (alias)": u"\uf1d4",
"yelp": u"\uf1e9",
"yen (alias)": u"\uf157",
"yoast": u"\uf2b1",
"youtube": u"\uf167",
"youtube-play": u"\uf16a",
"youtube-square": u"\uf166"
}

View file

@ -1,97 +0,0 @@
from qtpy import QtCore
EXPANDER_WIDTH = 20
def flags(*args, **kwargs):
type_name = kwargs.pop("type_name", "Flags")
with_base = kwargs.pop("with_base", False)
enums = {}
for idx, attr_name in enumerate(args):
if with_base:
if idx == 0:
enums[attr_name] = 0
continue
idx -= 1
enums[attr_name] = 2**idx
for attr_name, value in kwargs.items():
enums[attr_name] = value
return type(type_name, (), enums)
def roles(*args, **kwargs):
type_name = kwargs.pop("type_name", "Roles")
enums = {}
for attr_name, value in kwargs.items():
enums[attr_name] = value
offset = 0
for idx, attr_name in enumerate(args):
_idx = idx + QtCore.Qt.UserRole + offset
while _idx in enums.values():
offset += 1
_idx = idx + offset
enums[attr_name] = _idx
return type(type_name, (), enums)
Roles = roles(
"ObjectIdRole",
"ObjectUIdRole",
"TypeRole",
"PublishFlagsRole",
"LogRecordsRole",
"IsOptionalRole",
"IsEnabledRole",
"FamiliesRole",
"DocstringRole",
"PathModuleRole",
"PluginActionsVisibleRole",
"PluginValidActionsRole",
"PluginActionProgressRole",
"TerminalItemTypeRole",
"IntentItemValue",
type_name="ModelRoles"
)
InstanceStates = flags(
"ContextType",
"InProgress",
"HasWarning",
"HasError",
"HasFinished",
type_name="InstanceState"
)
PluginStates = flags(
"IsCompatible",
"InProgress",
"WasProcessed",
"WasSkipped",
"HasWarning",
"HasError",
type_name="PluginState"
)
GroupStates = flags(
"HasWarning",
"HasError",
"HasFinished",
type_name="GroupStates"
)
PluginActionStates = flags(
"InProgress",
"HasFailed",
"HasFinished",
type_name="PluginActionStates"
)

View file

@ -1,666 +0,0 @@
"""The Controller in a Model/View/Controller-based application
The graphical components of Pyblish Lite use this object to perform
publishing. It communicates via the Qt Signals/Slots mechanism
and has no direct connection to any graphics. This is important,
because this is how unittests are able to run without requiring
an active window manager; such as via Travis-CI.
"""
import os
import sys
import inspect
import logging
import collections
from qtpy import QtCore
import pyblish.api
import pyblish.util
import pyblish.logic
import pyblish.lib
import pyblish.version
from . import util
from .constants import InstanceStates
from ayon_core.settings import get_current_project_settings
class IterationBreak(Exception):
pass
class MainThreadItem:
"""Callback with args and kwargs."""
def __init__(self, callback, *args, **kwargs):
self.callback = callback
self.args = args
self.kwargs = kwargs
def process(self):
self.callback(*self.args, **self.kwargs)
class MainThreadProcess(QtCore.QObject):
"""Qt based main thread process executor.
Has timer which controls each 50ms if there is new item to process.
This approach gives ability to update UI meanwhile plugin is in progress.
"""
# How many times let pass QtApplication to process events
# - use 2 as resize event can trigger repaint event but not process in
# same loop
count_timeout = 2
def __init__(self):
super(MainThreadProcess, self).__init__()
self._items_to_process = collections.deque()
timer = QtCore.QTimer()
timer.setInterval(0)
timer.timeout.connect(self._execute)
self._timer = timer
self._switch_counter = self.count_timeout
def process(self, func, *args, **kwargs):
item = MainThreadItem(func, *args, **kwargs)
self.add_item(item)
def add_item(self, item):
self._items_to_process.append(item)
def _execute(self):
if not self._items_to_process:
return
if self._switch_counter > 0:
self._switch_counter -= 1
return
self._switch_counter = self.count_timeout
item = self._items_to_process.popleft()
item.process()
def start(self):
if not self._timer.isActive():
self._timer.start()
def stop(self):
if self._timer.isActive():
self._timer.stop()
def clear(self):
if self._timer.isActive():
self._timer.stop()
self._items_to_process = collections.deque()
def stop_if_empty(self):
if self._timer.isActive():
item = MainThreadItem(self._stop_if_empty)
self.add_item(item)
def _stop_if_empty(self):
if not self._items_to_process:
self.stop()
class Controller(QtCore.QObject):
log = logging.getLogger("PyblishController")
# Emitted when the GUI is about to start processing;
# e.g. resetting, validating or publishing.
about_to_process = QtCore.Signal(object, object)
# ??? Emitted for each process
was_processed = QtCore.Signal(dict)
# Emitted when reset
# - all data are reset (plugins, processing, pari yielder, etc.)
was_reset = QtCore.Signal()
# Emitted when previous group changed
passed_group = QtCore.Signal(object)
# Emitted when want to change state of instances
switch_toggleability = QtCore.Signal(bool)
# On action finished
was_acted = QtCore.Signal(dict)
# Emitted when processing has stopped
was_stopped = QtCore.Signal()
# Emitted when processing has finished
was_finished = QtCore.Signal()
# Emitted when plugin was skipped
was_skipped = QtCore.Signal(object)
# store OrderGroups - now it is a singleton
order_groups = util.OrderGroups
# When instance is toggled
instance_toggled = QtCore.Signal(object, object, object)
def __init__(self, parent=None):
super(Controller, self).__init__(parent)
self.context = None
self.plugins = {}
self.optional_default = {}
self.instance_toggled.connect(self._on_instance_toggled)
self._main_thread_processor = MainThreadProcess()
self._current_state = ""
def reset_variables(self):
self.log.debug("Resetting pyblish context variables")
# Data internal to the GUI itself
self.is_running = False
self.stopped = False
self.errored = False
self._current_state = ""
# Active producer of pairs
self.pair_generator = None
# Active pair
self.current_pair = None
# Orders which changes GUI
# - passing collectors order disables plugin/instance toggle
self.collect_state = 0
# - passing validators order disables validate button and gives ability
# to know when to stop on validate button press
self.validators_order = None
self.validated = False
# Get collectors and validators order
plugin_groups_keys = list(self.order_groups.groups.keys())
self.validators_order = self.order_groups.validation_order
next_group_order = None
if len(plugin_groups_keys) > 1:
next_group_order = plugin_groups_keys[1]
# This is used to track whether or not to continue
# processing when, for example, validation has failed.
self.processing = {
"stop_on_validation": False,
# Used?
"last_plugin_order": None,
"current_group_order": plugin_groups_keys[0],
"next_group_order": next_group_order,
"nextOrder": None,
"ordersWithError": set()
}
self._set_state_by_order()
self.log.debug("Reset of pyblish context variables done")
@property
def current_state(self):
return self._current_state
@staticmethod
def _convert_filter_presets(filter_presets):
"""Convert AYON settings presets to dictionary.
Returns:
dict[str, dict[str, Any]]: Filter presets converted to dictionary.
"""
if not isinstance(filter_presets, list):
return filter_presets
return {
filter_preset["name"]: {
item["name"]: item["value"]
for item in filter_preset["value"]
}
for filter_preset in filter_presets
}
def presets_by_hosts(self):
# Get global filters as base
presets = get_current_project_settings()
if not presets:
return {}
result = {}
hosts = pyblish.api.registered_hosts()
for host in hosts:
host_presets = presets.get(host, {}).get("filters")
if not host_presets:
continue
host_presets = self._convert_filter_presets(host_presets)
for key, value in host_presets.items():
if value is None:
if key in result:
result.pop(key)
continue
result[key] = value
return result
def reset_context(self):
self.log.debug("Resetting pyblish context object")
comment = None
if (
self.context is not None and
self.context.data.get("comment") and
# We only preserve the user typed comment if we are *not*
# resetting from a successful publish without errors
self._current_state != "Published"
):
comment = self.context.data["comment"]
self.context = pyblish.api.Context()
self.context._publish_states = InstanceStates.ContextType
self.context.optional = False
self.context.data["publish"] = True
self.context.data["name"] = "context"
self.context.data["host"] = reversed(pyblish.api.registered_hosts())
self.context.data["port"] = int(
os.environ.get("PYBLISH_CLIENT_PORT", -1)
)
self.context.data["connectTime"] = pyblish.lib.time(),
self.context.data["pyblishVersion"] = pyblish.version,
self.context.data["pythonVersion"] = sys.version
self.context.data["icon"] = "book"
self.context.families = ("__context__",)
if comment:
# Preserve comment on reset if user previously had a comment
self.context.data["comment"] = comment
self.log.debug("Reset of pyblish context object done")
def reset(self):
"""Discover plug-ins and run collection."""
self._main_thread_processor.clear()
self._main_thread_processor.process(self._reset)
self._main_thread_processor.start()
def _reset(self):
self.reset_context()
self.reset_variables()
self.possible_presets = self.presets_by_hosts()
# Load plugins and set pair generator
self.load_plugins()
self.pair_generator = self._pair_yielder(self.plugins)
self.was_reset.emit()
# Process collectors load rest of plugins with collected instances
self.collect()
def load_plugins(self):
self.test = pyblish.logic.registered_test()
self.optional_default = {}
plugins = pyblish.api.discover()
targets = set(pyblish.logic.registered_targets())
targets.add("default")
targets = list(targets)
plugins_by_targets = pyblish.logic.plugins_by_targets(plugins, targets)
_plugins = []
for plugin in plugins_by_targets:
# Skip plugin if is not optional and not active
if (
not getattr(plugin, "optional", False)
and not getattr(plugin, "active", True)
):
continue
_plugins.append(plugin)
self.plugins = _plugins
def on_published(self):
if self.is_running:
self.is_running = False
self._current_state = (
"Published" if not self.errored else "Published, with errors"
)
self.was_finished.emit()
self._main_thread_processor.stop()
def stop(self):
self.log.debug("Stopping")
self.stopped = True
def act(self, plugin, action):
self.is_running = True
item = MainThreadItem(self._process_action, plugin, action)
self._main_thread_processor.add_item(item)
self._main_thread_processor.start()
self._main_thread_processor.stop_if_empty()
def _process_action(self, plugin, action):
result = pyblish.plugin.process(
plugin, self.context, None, action.id
)
self.is_running = False
self.was_acted.emit(result)
def emit_(self, signal, kwargs):
pyblish.api.emit(signal, **kwargs)
def _process(self, plugin, instance=None):
"""Produce `result` from `plugin` and `instance`
:func:`process` shares state with :func:`_iterator` such that
an instance/plugin pair can be fetched and processed in isolation.
Arguments:
plugin (pyblish.api.Plugin): Produce result using plug-in
instance (optional, pyblish.api.Instance): Process this instance,
if no instance is provided, context is processed.
"""
self.processing["nextOrder"] = plugin.order
try:
result = pyblish.plugin.process(plugin, self.context, instance)
# Make note of the order at which the
# potential error error occurred.
if result["error"] is not None:
self.processing["ordersWithError"].add(plugin.order)
except Exception as exc:
raise Exception("Unknown error({}): {}".format(
plugin.__name__, str(exc)
))
return result
def _pair_yielder(self, plugins):
for plugin in plugins:
if (
self.processing["current_group_order"] is not None
and plugin.order > self.processing["current_group_order"]
):
current_group_order = self.processing["current_group_order"]
new_next_group_order = None
new_current_group_order = self.processing["next_group_order"]
if new_current_group_order is not None:
current_next_order_found = False
for order in self.order_groups.groups.keys():
if current_next_order_found:
new_next_group_order = order
break
if order == new_current_group_order:
current_next_order_found = True
self.processing["next_group_order"] = new_next_group_order
self.processing["current_group_order"] = (
new_current_group_order
)
# Force update to the current state
self._set_state_by_order()
if self.collect_state == 0:
self.collect_state = 1
self._current_state = (
"Ready" if not self.errored else
"Collected, with errors"
)
self.switch_toggleability.emit(True)
self.passed_group.emit(current_group_order)
yield IterationBreak("Collected")
else:
self.passed_group.emit(current_group_order)
if self.errored:
self._current_state = (
"Stopped, due to errors" if not
self.processing["stop_on_validation"] else
"Validated, with errors"
)
yield IterationBreak("Last group errored")
if self.collect_state == 1:
self.collect_state = 2
self.switch_toggleability.emit(False)
if not self.validated and plugin.order > self.validators_order:
self.validated = True
if self.processing["stop_on_validation"]:
self._current_state = (
"Validated" if not self.errored else
"Validated, with errors"
)
yield IterationBreak("Validated")
# Stop if was stopped
if self.stopped:
self.stopped = False
self._current_state = "Paused"
yield IterationBreak("Stopped")
# check test if will stop
self.processing["nextOrder"] = plugin.order
message = self.test(**self.processing)
if message:
self._current_state = "Paused"
yield IterationBreak("Stopped due to \"{}\"".format(message))
self.processing["last_plugin_order"] = plugin.order
if not plugin.active:
pyblish.logic.log.debug("%s was inactive, skipping.." % plugin)
self.was_skipped.emit(plugin)
continue
in_collect_stage = self.collect_state == 0
if plugin.__instanceEnabled__:
instances = pyblish.logic.instances_by_plugin(
self.context, plugin
)
if not instances:
self.was_skipped.emit(plugin)
continue
for instance in instances:
if (
not in_collect_stage
and instance.data.get("publish") is False
):
pyblish.logic.log.debug(
"%s was inactive, skipping.." % instance
)
continue
# Stop if was stopped
if self.stopped:
self.stopped = False
self._current_state = "Paused"
yield IterationBreak("Stopped")
yield (plugin, instance)
else:
families = util.collect_families_from_instances(
self.context, only_active=not in_collect_stage
)
plugins = pyblish.logic.plugins_by_families(
[plugin], families
)
if not plugins:
self.was_skipped.emit(plugin)
continue
yield (plugin, None)
self.passed_group.emit(self.processing["next_group_order"])
def iterate_and_process(self, on_finished=None):
""" Iterating inserted plugins with current context.
Collectors do not contain instances, they are None when collecting!
This process don't stop on one
"""
self._main_thread_processor.start()
def on_next():
self.log.debug("Looking for next pair to process")
try:
self.current_pair = next(self.pair_generator)
if isinstance(self.current_pair, IterationBreak):
raise self.current_pair
except IterationBreak:
self.log.debug("Iteration break was raised")
self.is_running = False
self.was_stopped.emit()
self._main_thread_processor.stop()
return
except StopIteration:
self.log.debug("Iteration stop was raised")
self.is_running = False
# All pairs were processed successfully!
if on_finished is not None:
self._main_thread_processor.add_item(
MainThreadItem(on_finished)
)
self._main_thread_processor.stop_if_empty()
return
except Exception as exc:
self.log.warning(
"Unexpected exception during `on_next` happened",
exc_info=True
)
exc_msg = str(exc)
self._main_thread_processor.add_item(
MainThreadItem(on_unexpected_error, error=exc_msg)
)
return
self.about_to_process.emit(*self.current_pair)
self._main_thread_processor.add_item(
MainThreadItem(on_process)
)
def on_process():
try:
self.log.debug(
"Processing pair: {}".format(str(self.current_pair))
)
result = self._process(*self.current_pair)
if result["error"] is not None:
self.log.debug("Error happened")
self.errored = True
self.log.debug("Pair processed")
self.was_processed.emit(result)
except Exception as exc:
self.log.warning(
"Unexpected exception during `on_process` happened",
exc_info=True
)
exc_msg = str(exc)
self._main_thread_processor.add_item(
MainThreadItem(on_unexpected_error, error=exc_msg)
)
return
self._main_thread_processor.add_item(
MainThreadItem(on_next)
)
def on_unexpected_error(error):
# TODO this should be handled much differently
# TODO emit crash signal to show message box with traceback?
self.is_running = False
self.was_stopped.emit()
util.u_print(u"An unexpected error occurred:\n %s" % error)
if on_finished is not None:
self._main_thread_processor.add_item(
MainThreadItem(on_finished)
)
self._main_thread_processor.stop_if_empty()
self.is_running = True
self._main_thread_processor.add_item(
MainThreadItem(on_next)
)
def _set_state_by_order(self):
order = self.processing["current_group_order"]
self._current_state = self.order_groups.groups[order]["state"]
def collect(self):
""" Iterate and process Collect plugins
- load_plugins method is launched again when finished
"""
self._set_state_by_order()
self._main_thread_processor.process(self._start_collect)
self._main_thread_processor.start()
def validate(self):
""" Process plugins to validations_order value."""
self._set_state_by_order()
self._main_thread_processor.process(self._start_validate)
self._main_thread_processor.start()
def publish(self):
""" Iterate and process all remaining plugins."""
self._set_state_by_order()
self._main_thread_processor.process(self._start_publish)
self._main_thread_processor.start()
def _start_collect(self):
self.iterate_and_process()
def _start_validate(self):
self.processing["stop_on_validation"] = True
self.iterate_and_process()
def _start_publish(self):
self.processing["stop_on_validation"] = False
self.iterate_and_process(self.on_published)
def cleanup(self):
"""Forcefully delete objects from memory
In an ideal world, this shouldn't be necessary. Garbage
collection guarantees that anything without reference
is automatically removed.
However, because this application is designed to be run
multiple times from the same interpreter process, extra
case must be taken to ensure there are no memory leaks.
Explicitly deleting objects shines a light on where objects
may still be referenced in the form of an error. No errors
means this was unnecessary, but that's ok.
"""
for instance in self.context:
del(instance)
for plugin in self.plugins:
del(plugin)
def _on_instance_toggled(self, instance, old_value, new_value):
callbacks = pyblish.api.registered_callbacks().get("instanceToggled")
if not callbacks:
return
for callback in callbacks:
try:
callback(instance, old_value, new_value)
except Exception:
self.log.warning(
"Callback for `instanceToggled` crashed. {}".format(
os.path.abspath(inspect.getfile(callback))
),
exc_info=True
)

View file

@ -1,540 +0,0 @@
import platform
from qtpy import QtWidgets, QtGui, QtCore
from . import model
from .awesome import tags as awesome
from .constants import (
PluginStates, InstanceStates, PluginActionStates, Roles, EXPANDER_WIDTH
)
colors = {
"error": QtGui.QColor("#ff4a4a"),
"warning": QtGui.QColor("#ff9900"),
"ok": QtGui.QColor("#77AE24"),
"active": QtGui.QColor("#99CEEE"),
"idle": QtCore.Qt.white,
"inactive": QtGui.QColor("#888"),
"hover": QtGui.QColor(255, 255, 255, 10),
"selected": QtGui.QColor(255, 255, 255, 20),
"outline": QtGui.QColor("#333"),
"group": QtGui.QColor("#333"),
"group-hover": QtGui.QColor("#3c3c3c"),
"group-selected-hover": QtGui.QColor("#555555"),
"expander-bg": QtGui.QColor("#222"),
"expander-hover": QtGui.QColor("#2d6c9f"),
"expander-selected-hover": QtGui.QColor("#3784c5")
}
scale_factors = {"darwin": 1.5}
scale_factor = scale_factors.get(platform.system().lower(), 1.0)
fonts = {
"h3": QtGui.QFont("Open Sans", 10 * scale_factor, QtGui.QFont.Normal),
"h4": QtGui.QFont("Open Sans", 8 * scale_factor, QtGui.QFont.Normal),
"h5": QtGui.QFont("Open Sans", 8 * scale_factor, QtGui.QFont.DemiBold),
"awesome6": QtGui.QFont("FontAwesome", 6 * scale_factor),
"awesome10": QtGui.QFont("FontAwesome", 10 * scale_factor),
"smallAwesome": QtGui.QFont("FontAwesome", 8 * scale_factor),
"largeAwesome": QtGui.QFont("FontAwesome", 16 * scale_factor),
}
font_metrics = {
"awesome6": QtGui.QFontMetrics(fonts["awesome6"]),
"h4": QtGui.QFontMetrics(fonts["h4"]),
"h5": QtGui.QFontMetrics(fonts["h5"])
}
icons = {
"action": awesome["adn"],
"angle-right": awesome["angle-right"],
"angle-left": awesome["angle-left"],
"plus-sign": awesome['plus'],
"minus-sign": awesome['minus']
}
class PluginItemDelegate(QtWidgets.QStyledItemDelegate):
"""Generic delegate for model items"""
def paint(self, painter, option, index):
"""Paint checkbox and text.
_
|_| My label >
"""
body_rect = QtCore.QRectF(option.rect)
check_rect = QtCore.QRectF(body_rect)
check_rect.setWidth(check_rect.height())
check_offset = (check_rect.height() / 4) + 1
check_rect.adjust(
check_offset, check_offset, -check_offset, -check_offset
)
check_color = colors["idle"]
perspective_icon = icons["angle-right"]
perspective_rect = QtCore.QRectF(body_rect)
perspective_rect.setWidth(perspective_rect.height())
perspective_rect.adjust(0, 3, 0, 0)
perspective_rect.translate(
body_rect.width() - (perspective_rect.width() / 2 + 2),
0
)
publish_states = index.data(Roles.PublishFlagsRole)
if publish_states & PluginStates.InProgress:
check_color = colors["active"]
elif publish_states & PluginStates.HasError:
check_color = colors["error"]
elif publish_states & PluginStates.HasWarning:
check_color = colors["warning"]
elif publish_states & PluginStates.WasProcessed:
check_color = colors["ok"]
elif not index.data(Roles.IsEnabledRole):
check_color = colors["inactive"]
offset = (body_rect.height() - font_metrics["h4"].height()) / 2
label_rect = QtCore.QRectF(body_rect.adjusted(
check_rect.width() + 12, offset - 1, 0, 0
))
assert label_rect.width() > 0
label = index.data(QtCore.Qt.DisplayRole)
label = font_metrics["h4"].elidedText(
label,
QtCore.Qt.ElideRight,
label_rect.width() - 20
)
font_color = colors["idle"]
if not index.data(QtCore.Qt.CheckStateRole):
font_color = colors["inactive"]
# Maintain reference to state, so we can restore it once we're done
painter.save()
# Draw perspective icon
painter.setFont(fonts["awesome10"])
painter.setPen(QtGui.QPen(font_color))
painter.drawText(perspective_rect, perspective_icon)
# Draw label
painter.setFont(fonts["h4"])
painter.setPen(QtGui.QPen(font_color))
painter.drawText(label_rect, label)
# Draw action icon
if index.data(Roles.PluginActionsVisibleRole):
painter.save()
action_state = index.data(Roles.PluginActionProgressRole)
if action_state & PluginActionStates.HasFailed:
color = colors["error"]
elif action_state & PluginActionStates.HasFinished:
color = colors["ok"]
elif action_state & PluginActionStates.InProgress:
color = colors["active"]
else:
color = colors["idle"]
painter.setFont(fonts["smallAwesome"])
painter.setPen(QtGui.QPen(color))
icon_rect = QtCore.QRectF(
option.rect.adjusted(
label_rect.width() - perspective_rect.width() / 2,
label_rect.height() / 3, 0, 0
)
)
painter.drawText(icon_rect, icons["action"])
painter.restore()
# Draw checkbox
pen = QtGui.QPen(check_color, 1)
painter.setPen(pen)
if index.data(Roles.IsOptionalRole):
painter.drawRect(check_rect)
if index.data(QtCore.Qt.CheckStateRole):
optional_check_rect = QtCore.QRectF(check_rect)
optional_check_rect.adjust(2, 2, -1, -1)
painter.fillRect(optional_check_rect, check_color)
else:
painter.fillRect(check_rect, check_color)
if option.state & QtWidgets.QStyle.State_MouseOver:
painter.fillRect(body_rect, colors["hover"])
if option.state & QtWidgets.QStyle.State_Selected:
painter.fillRect(body_rect, colors["selected"])
# Ok, we're done, tidy up.
painter.restore()
def sizeHint(self, option, index):
return QtCore.QSize(option.rect.width(), 20)
class InstanceItemDelegate(QtWidgets.QStyledItemDelegate):
"""Generic delegate for model items"""
def paint(self, painter, option, index):
"""Paint checkbox and text.
_
|_| My label >
"""
body_rect = QtCore.QRectF(option.rect)
check_rect = QtCore.QRectF(body_rect)
check_rect.setWidth(check_rect.height())
offset = (check_rect.height() / 4) + 1
check_rect.adjust(offset, offset, -(offset), -(offset))
check_color = colors["idle"]
perspective_icon = icons["angle-right"]
perspective_rect = QtCore.QRectF(body_rect)
perspective_rect.setWidth(perspective_rect.height())
perspective_rect.adjust(0, 3, 0, 0)
perspective_rect.translate(
body_rect.width() - (perspective_rect.width() / 2 + 2),
0
)
publish_states = index.data(Roles.PublishFlagsRole)
if publish_states & InstanceStates.InProgress:
check_color = colors["active"]
elif publish_states & InstanceStates.HasError:
check_color = colors["error"]
elif publish_states & InstanceStates.HasWarning:
check_color = colors["warning"]
elif publish_states & InstanceStates.HasFinished:
check_color = colors["ok"]
elif not index.data(Roles.IsEnabledRole):
check_color = colors["inactive"]
offset = (body_rect.height() - font_metrics["h4"].height()) / 2
label_rect = QtCore.QRectF(body_rect.adjusted(
check_rect.width() + 12, offset - 1, 0, 0
))
assert label_rect.width() > 0
label = index.data(QtCore.Qt.DisplayRole)
label = font_metrics["h4"].elidedText(
label,
QtCore.Qt.ElideRight,
label_rect.width() - 20
)
font_color = colors["idle"]
if not index.data(QtCore.Qt.CheckStateRole):
font_color = colors["inactive"]
# Maintain reference to state, so we can restore it once we're done
painter.save()
# Draw perspective icon
painter.setFont(fonts["awesome10"])
painter.setPen(QtGui.QPen(font_color))
painter.drawText(perspective_rect, perspective_icon)
# Draw label
painter.setFont(fonts["h4"])
painter.setPen(QtGui.QPen(font_color))
painter.drawText(label_rect, label)
# Draw checkbox
pen = QtGui.QPen(check_color, 1)
painter.setPen(pen)
if index.data(Roles.IsOptionalRole):
painter.drawRect(check_rect)
if index.data(QtCore.Qt.CheckStateRole):
optional_check_rect = QtCore.QRectF(check_rect)
optional_check_rect.adjust(2, 2, -1, -1)
painter.fillRect(optional_check_rect, check_color)
else:
painter.fillRect(check_rect, check_color)
if option.state & QtWidgets.QStyle.State_MouseOver:
painter.fillRect(body_rect, colors["hover"])
if option.state & QtWidgets.QStyle.State_Selected:
painter.fillRect(body_rect, colors["selected"])
# Ok, we're done, tidy up.
painter.restore()
def sizeHint(self, option, index):
return QtCore.QSize(option.rect.width(), 20)
class InstanceDelegate(QtWidgets.QStyledItemDelegate):
"""Generic delegate for instance header"""
radius = 8.0
def __init__(self, parent):
super(InstanceDelegate, self).__init__(parent)
self.item_delegate = InstanceItemDelegate(parent)
def paint(self, painter, option, index):
if index.data(Roles.TypeRole) in (
model.InstanceType, model.PluginType
):
self.item_delegate.paint(painter, option, index)
return
self.group_item_paint(painter, option, index)
def group_item_paint(self, painter, option, index):
"""Paint text
_
My label
"""
body_rect = QtCore.QRectF(option.rect)
bg_rect = QtCore.QRectF(
body_rect.left(), body_rect.top() + 1,
body_rect.width() - 5, body_rect.height() - 2
)
expander_rect = QtCore.QRectF(bg_rect)
expander_rect.setWidth(EXPANDER_WIDTH)
remainder_rect = QtCore.QRectF(
expander_rect.x() + expander_rect.width(),
expander_rect.y(),
bg_rect.width() - expander_rect.width(),
expander_rect.height()
)
width = float(expander_rect.width())
height = float(expander_rect.height())
x_pos = expander_rect.x()
y_pos = expander_rect.y()
x_radius = min(self.radius, width / 2)
y_radius = min(self.radius, height / 2)
x_radius2 = x_radius * 2
y_radius2 = y_radius * 2
expander_path = QtGui.QPainterPath()
expander_path.moveTo(x_pos, y_pos + y_radius)
expander_path.arcTo(
x_pos, y_pos,
x_radius2, y_radius2,
180.0, -90.0
)
expander_path.lineTo(x_pos + width, y_pos)
expander_path.lineTo(x_pos + width, y_pos + height)
expander_path.lineTo(x_pos + x_radius, y_pos + height)
expander_path.arcTo(
x_pos, y_pos + height - y_radius2,
x_radius2, y_radius2,
270.0, -90.0
)
expander_path.closeSubpath()
width = float(remainder_rect.width())
height = float(remainder_rect.height())
x_pos = remainder_rect.x()
y_pos = remainder_rect.y()
x_radius = min(self.radius, width / 2)
y_radius = min(self.radius, height / 2)
x_radius2 = x_radius * 2
y_radius2 = y_radius * 2
remainder_path = QtGui.QPainterPath()
remainder_path.moveTo(x_pos + width, y_pos + height - y_radius)
remainder_path.arcTo(
x_pos + width - x_radius2, y_pos + height - y_radius2,
x_radius2, y_radius2,
0.0, -90.0
)
remainder_path.lineTo(x_pos, y_pos + height)
remainder_path.lineTo(x_pos, y_pos)
remainder_path.lineTo(x_pos + width - x_radius, y_pos)
remainder_path.arcTo(
x_pos + width - x_radius2, y_pos,
x_radius2, y_radius2,
90.0, -90.0
)
remainder_path.closeSubpath()
painter.fillPath(expander_path, colors["expander-bg"])
painter.fillPath(remainder_path, colors["group"])
mouse_pos = option.widget.mapFromGlobal(QtGui.QCursor.pos())
selected = option.state & QtWidgets.QStyle.State_Selected
hovered = option.state & QtWidgets.QStyle.State_MouseOver
if selected and hovered:
if expander_rect.contains(mouse_pos):
painter.fillPath(
expander_path, colors["expander-selected-hover"]
)
else:
painter.fillPath(
remainder_path, colors["group-selected-hover"]
)
elif hovered:
if expander_rect.contains(mouse_pos):
painter.fillPath(expander_path, colors["expander-hover"])
else:
painter.fillPath(remainder_path, colors["group-hover"])
text_height = font_metrics["awesome6"].height()
adjust_value = (expander_rect.height() - text_height) / 2
expander_rect.adjust(
adjust_value + 1.5, adjust_value - 0.5,
-adjust_value + 1.5, -adjust_value - 0.5
)
offset = (remainder_rect.height() - font_metrics["h5"].height()) / 2
label_rect = QtCore.QRectF(remainder_rect.adjusted(
5, offset - 1, 0, 0
))
expander_icon = icons["plus-sign"]
expanded = self.parent().isExpanded(index)
if expanded:
expander_icon = icons["minus-sign"]
label = index.data(QtCore.Qt.DisplayRole)
label = font_metrics["h5"].elidedText(
label, QtCore.Qt.ElideRight, label_rect.width()
)
# Maintain reference to state, so we can restore it once we're done
painter.save()
painter.setFont(fonts["awesome6"])
painter.setPen(QtGui.QPen(colors["idle"]))
painter.drawText(expander_rect, QtCore.Qt.AlignCenter, expander_icon)
# Draw label
painter.setFont(fonts["h5"])
painter.drawText(label_rect, label)
# Ok, we're done, tidy up.
painter.restore()
def sizeHint(self, option, index):
return QtCore.QSize(option.rect.width(), 20)
class PluginDelegate(QtWidgets.QStyledItemDelegate):
"""Generic delegate for plugin header"""
def __init__(self, parent):
super(PluginDelegate, self).__init__(parent)
self.item_delegate = PluginItemDelegate(parent)
def paint(self, painter, option, index):
if index.data(Roles.TypeRole) in (
model.InstanceType, model.PluginType
):
self.item_delegate.paint(painter, option, index)
return
self.group_item_paint(painter, option, index)
def group_item_paint(self, painter, option, index):
"""Paint text
_
My label
"""
body_rect = QtCore.QRectF(option.rect)
bg_rect = QtCore.QRectF(
body_rect.left(), body_rect.top() + 1,
body_rect.width() - 5, body_rect.height() - 2
)
radius = 8.0
bg_path = QtGui.QPainterPath()
bg_path.addRoundedRect(bg_rect, radius, radius)
hovered = option.state & QtWidgets.QStyle.State_MouseOver
selected = option.state & QtWidgets.QStyle.State_Selected
if hovered and selected:
painter.fillPath(bg_path, colors["group-selected-hover"])
elif hovered:
painter.fillPath(bg_path, colors["group-hover"])
else:
painter.fillPath(bg_path, colors["group"])
expander_rect = QtCore.QRectF(bg_rect)
expander_rect.setWidth(expander_rect.height())
text_height = font_metrics["awesome6"].height()
adjust_value = (expander_rect.height() - text_height) / 2
expander_rect.adjust(
adjust_value + 1.5, adjust_value - 0.5,
-adjust_value + 1.5, -adjust_value - 0.5
)
offset = (bg_rect.height() - font_metrics["h5"].height()) / 2
label_rect = QtCore.QRectF(bg_rect.adjusted(
expander_rect.width() + 12, offset - 1, 0, 0
))
assert label_rect.width() > 0
expander_icon = icons["plus-sign"]
expanded = self.parent().isExpanded(index)
if expanded:
expander_icon = icons["minus-sign"]
label = index.data(QtCore.Qt.DisplayRole)
label = font_metrics["h5"].elidedText(
label, QtCore.Qt.ElideRight, label_rect.width()
)
# Maintain reference to state, so we can restore it once we're done
painter.save()
painter.setFont(fonts["awesome6"])
painter.setPen(QtGui.QPen(colors["idle"]))
painter.drawText(expander_rect, QtCore.Qt.AlignCenter, expander_icon)
# Draw label
painter.setFont(fonts["h5"])
painter.drawText(label_rect, label)
# Ok, we're done, tidy up.
painter.restore()
def sizeHint(self, option, index):
return QtCore.QSize(option.rect.width(), 20)
class TerminalItem(QtWidgets.QStyledItemDelegate):
"""Delegate used exclusively for the Terminal"""
def paint(self, painter, option, index):
super(TerminalItem, self).paint(painter, option, index)
item_type = index.data(Roles.TypeRole)
if item_type == model.TerminalDetailType:
return
hover = QtGui.QPainterPath()
hover.addRect(QtCore.QRectF(option.rect).adjusted(0, 0, -1, -1))
if option.state & QtWidgets.QStyle.State_Selected:
painter.fillPath(hover, colors["selected"])
if option.state & QtWidgets.QStyle.State_MouseOver:
painter.fillPath(hover, colors["hover"])

View file

@ -1,202 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View file

@ -1,2 +0,0 @@
SOURCES = ../window.py
TRANSLATIONS = zh_CN.ts

View file

@ -1,96 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE TS><TS version="1.1" language="zh_CN">
<context>
<name>Window</name>
<message>
<location filename="../window.py" line="763"/>
<source>Finishing up reset..</source>
<translation>..</translation>
</message>
<message>
<location filename="../window.py" line="228"/>
<source>Comment..</source>
<translation>..</translation>
</message>
<message>
<location filename="../window.py" line="722"/>
<source>Processing</source>
<translation></translation>
</message>
<message>
<location filename="../window.py" line="877"/>
<source>Stopped due to error(s), see Terminal.</source>
<translation>, </translation>
</message>
<message>
<location filename="../window.py" line="879"/>
<source>Finished successfully!</source>
<translation>!</translation>
</message>
<message>
<location filename="../window.py" line="889"/>
<source>About to reset..</source>
<translation>..</translation>
</message>
<message>
<location filename="../window.py" line="911"/>
<source>Preparing validate..</source>
<translation>..</translation>
</message>
<message>
<location filename="../window.py" line="919"/>
<source>Preparing publish..</source>
<translation>..</translation>
</message>
<message>
<location filename="../window.py" line="928"/>
<source>Preparing</source>
<translation></translation>
</message>
<message>
<location filename="../window.py" line="951"/>
<source>Action prepared.</source>
<translation></translation>
</message>
<message>
<location filename="../window.py" line="969"/>
<source>Cleaning up models..</source>
<translation>..</translation>
</message>
<message>
<location filename="../window.py" line="974"/>
<source>Cleaning up terminal..</source>
<translation>..</translation>
</message>
<message>
<location filename="../window.py" line="978"/>
<source>Cleaning up controller..</source>
<translation>..</translation>
</message>
<message>
<location filename="../window.py" line="981"/>
<source>All clean!</source>
<translation>!</translation>
</message>
<message>
<location filename="../window.py" line="982"/>
<source>Good bye</source>
<translation></translation>
</message>
<message>
<location filename="../window.py" line="993"/>
<source>..as soon as processing is finished..</source>
<translation>....</translation>
</message>
<message>
<location filename="../window.py" line="1008"/>
<source>Stopping..</source>
<translation>..</translation>
</message>
<message>
<location filename="../window.py" line="985"/>
<source>Closing..</source>
<translation>..</translation>
</message>
</context>
</TS>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 B

View file

@ -1,732 +0,0 @@
import os
import time
import subprocess
import pyblish.api
class MyAction(pyblish.api.Action):
label = "My Action"
on = "processed"
def process(self, context, plugin):
self.log.info("Running!")
class MyOtherAction(pyblish.api.Action):
label = "My Other Action"
def process(self, context, plugin):
self.log.info("Running!")
class CollectComment(pyblish.api.ContextPlugin):
"""This collector has a very long comment.
The idea is that this comment should either be elided, or word-
wrapped in the corresponding view.
"""
order = pyblish.api.CollectorOrder
def process(self, context):
context.data["comment"] = ""
class MyCollector(pyblish.api.ContextPlugin):
label = "My Collector"
order = pyblish.api.CollectorOrder
def process(self, context):
context.create_instance("MyInstance 1", families=["myFamily"])
context.create_instance("MyInstance 2", families=["myFamily 2"])
context.create_instance(
"MyInstance 3",
families=["myFamily 2"],
publish=False
)
class MyValidator(pyblish.api.InstancePlugin):
order = pyblish.api.ValidatorOrder
active = False
label = "My Validator"
actions = [MyAction,
MyOtherAction]
def process(self, instance):
self.log.info("Validating: %s" % instance)
class MyExtractor(pyblish.api.InstancePlugin):
order = pyblish.api.ExtractorOrder
families = ["myFamily"]
label = "My Extractor"
def process(self, instance):
self.log.info("Extracting: %s" % instance)
class CollectRenamed(pyblish.api.Collector):
def process(self, context):
i = context.create_instance("MyInstanceXYZ", family="MyFamily")
i.set_data("name", "My instance")
class CollectNegatron(pyblish.api.Collector):
"""Negative collector adds Negatron"""
order = pyblish.api.Collector.order - 0.49
def process_context(self, context):
self.log.info("Collecting Negatron")
context.create_instance("Negatron", family="MyFamily")
class CollectPositron(pyblish.api.Collector):
"""Positive collector adds Positron"""
order = pyblish.api.Collector.order + 0.49
def process_context(self, context):
self.log.info("Collecting Positron")
context.create_instance("Positron", family="MyFamily")
class SelectInstances(pyblish.api.Selector):
"""Select debugging instances
These instances are part of the evil plan to destroy the world.
Be weary, be vigilant, be sexy.
"""
def process_context(self, context):
self.log.info("Selecting instances..")
for instance in instances[:-1]:
name, data = instance["name"], instance["data"]
self.log.info("Selecting: %s" % name)
instance = context.create_instance(name)
for key, value in data.items():
instance.set_data(key, value)
class SelectDiInstances(pyblish.api.Selector):
"""Select DI instances"""
name = "Select Dependency Instances"
def process(self, context):
name, data = instances[-1]["name"], instances[-1]["data"]
self.log.info("Selecting: %s" % name)
instance = context.create_instance(name)
for key, value in data.items():
instance.set_data(key, value)
class SelectInstancesFailure(pyblish.api.Selector):
"""Select some instances, but fail before adding anything to the context.
That's right. I'm programmed to fail. Try me.
"""
__fail__ = True
def process_context(self, context):
self.log.warning("I'm about to fail")
raise AssertionError("I was programmed to fail")
class SelectInstances2(pyblish.api.Selector):
def process(self, context):
self.log.warning("I'm good")
class ValidateNamespace(pyblish.api.Validator):
"""Namespaces must be orange
In case a namespace is not orange, report immediately to
your officer in charge, ask for a refund, do a backflip.
This has been an example of:
- A long doc-string
- With a list
- And plenty of newlines and tabs.
"""
families = ["B"]
def process(self, instance):
self.log.info("Validating the namespace of %s" % instance.data("name"))
self.log.info("""And here's another message, quite long, in fact it's
too long to be displayed in a single row of text.
But that's how we roll down here. It's got \nnew lines\nas well.
- And lists
- And more lists
""")
class ValidateContext(pyblish.api.Validator):
families = ["A", "B"]
def process_context(self, context):
self.log.info("Processing context..")
class ValidateContextFailure(pyblish.api.Validator):
optional = True
families = ["C"]
__fail__ = True
def process_context(self, context):
self.log.info("About to fail..")
raise AssertionError("""I was programmed to fail
The reason I failed was because the sun was not aligned with the tides,
and the moon is gray; not yellow. Try again when the moon is yellow.""")
class Validator1(pyblish.api.Validator):
"""Test of the order attribute"""
order = pyblish.api.Validator.order + 0.1
families = ["A"]
def process_instance(self, instance):
pass
class Validator2(pyblish.api.Validator):
order = pyblish.api.Validator.order + 0.2
families = ["B"]
def process_instance(self, instance):
pass
class Validator3(pyblish.api.Validator):
order = pyblish.api.Validator.order + 0.3
families = ["B"]
def process_instance(self, instance):
pass
class ValidateFailureMock(pyblish.api.Validator):
"""Plug-in that always fails"""
optional = True
order = pyblish.api.Validator.order + 0.1
families = ["C"]
__fail__ = True
def process_instance(self, instance):
self.log.debug("e = mc^2")
self.log.info("About to fail..")
self.log.warning("Failing.. soooon..")
self.log.critical("Ok, you're done.")
raise AssertionError("""ValidateFailureMock was destined to fail..
Here's some extended information about what went wrong.
It has quite the long string associated with it, including
a few newlines and a list.
- Item 1
- Item 2
""")
class ValidateIsIncompatible(pyblish.api.Validator):
"""This plug-in should never appear.."""
requires = False # This is invalid
class ValidateWithRepair(pyblish.api.Validator):
"""A validator with repair functionality"""
optional = True
families = ["C"]
__fail__ = True
def process_instance(self, instance):
raise AssertionError(
"%s is invalid, try repairing it!" % instance.name
)
def repair_instance(self, instance):
self.log.info("Attempting to repair..")
self.log.info("Success!")
class ValidateWithRepairFailure(pyblish.api.Validator):
"""A validator with repair functionality that fails"""
optional = True
families = ["C"]
__fail__ = True
def process_instance(self, instance):
raise AssertionError(
"%s is invalid, try repairing it!" % instance.name
)
def repair_instance(self, instance):
self.log.info("Attempting to repair..")
raise AssertionError("Could not repair due to X")
class ValidateWithVeryVeryVeryLongLongNaaaaame(pyblish.api.Validator):
"""A validator with repair functionality that fails"""
families = ["A"]
class ValidateWithRepairContext(pyblish.api.Validator):
"""A validator with repair functionality that fails"""
optional = True
families = ["C"]
__fail__ = True
def process_context(self, context):
raise AssertionError("Could not validate context, try repairing it")
def repair_context(self, context):
self.log.info("Attempting to repair..")
raise AssertionError("Could not repair")
class ExtractAsMa(pyblish.api.Extractor):
"""Extract contents of each instance into .ma
Serialise scene using Maya's own facilities and then put
it on the hard-disk. Once complete, this plug-in relies
on a Conformer to put it in it's final location, as this
extractor merely positions it in the users local temp-
directory.
"""
optional = True
__expected__ = {
"logCount": ">=4"
}
def process_instance(self, instance):
self.log.info("About to extract scene to .ma..")
self.log.info("Extraction went well, now verifying the data..")
if instance.name == "Richard05":
self.log.warning("You're almost running out of disk space!")
self.log.info("About to finish up")
self.log.info("Finished successfully")
class ConformAsset(pyblish.api.Conformer):
"""Conform the world
Step 1: Conform all humans and Step 2: Conform all non-humans.
Once conforming has completed, rinse and repeat.
"""
optional = True
def process_instance(self, instance):
self.log.info("About to conform all humans..")
if instance.name == "Richard05":
self.log.warning("Richard05 is a conformist!")
self.log.info("About to conform all non-humans..")
self.log.info("Conformed Successfully")
class ValidateInstancesDI(pyblish.api.Validator):
"""Validate using the DI interface"""
families = ["diFamily"]
def process(self, instance):
self.log.info("Validating %s.." % instance.data("name"))
class ValidateDIWithRepair(pyblish.api.Validator):
families = ["diFamily"]
optional = True
__fail__ = True
def process(self, instance):
raise AssertionError("I was programmed to fail, for repair")
def repair(self, instance):
self.log.info("Repairing %s" % instance.data("name"))
class ExtractInstancesDI(pyblish.api.Extractor):
"""Extract using the DI interface"""
families = ["diFamily"]
def process(self, instance):
self.log.info("Extracting %s.." % instance.data("name"))
class ValidateWithLabel(pyblish.api.Validator):
"""Validate using the DI interface"""
label = "Validate with Label"
class ValidateWithLongLabel(pyblish.api.Validator):
"""Validate using the DI interface"""
label = "Validate with Loooooooooooooooooooooong Label"
class SimplePlugin1(pyblish.api.Plugin):
"""Validate using the simple-plugin interface"""
def process(self):
self.log.info("I'm a simple plug-in, only processed once")
class SimplePlugin2(pyblish.api.Plugin):
"""Validate using the simple-plugin interface
It doesn't have an order, and will likely end up *before* all
other plug-ins. (due to how sorted([1, 2, 3, None]) works)
"""
def process(self, context):
self.log.info("Processing the context, simply: %s" % context)
class SimplePlugin3(pyblish.api.Plugin):
"""Simply process every instance"""
def process(self, instance):
self.log.info("Processing the instance, simply: %s" % instance)
class ContextAction(pyblish.api.Action):
label = "Context action"
def process(self, context):
self.log.info("I have access to the context")
self.log.info("Context.instances: %s" % str(list(context)))
class FailingAction(pyblish.api.Action):
label = "Failing action"
def process(self):
self.log.info("About to fail..")
raise Exception("I failed")
class LongRunningAction(pyblish.api.Action):
label = "Long-running action"
def process(self):
self.log.info("Sleeping for 2 seconds..")
time.sleep(2)
self.log.info("Ah, that's better")
class IconAction(pyblish.api.Action):
label = "Icon action"
icon = "crop"
def process(self):
self.log.info("I have an icon")
class PluginAction(pyblish.api.Action):
label = "Plugin action"
def process(self, plugin):
self.log.info("I have access to my parent plug-in")
self.log.info("Which is %s" % plugin.id)
class LaunchExplorerAction(pyblish.api.Action):
label = "Open in Explorer"
icon = "folder-open"
def process(self, context):
cwd = os.getcwd()
self.log.info("Opening %s in Explorer" % cwd)
result = subprocess.call("start .", cwd=cwd, shell=True)
self.log.debug(result)
class ProcessedAction(pyblish.api.Action):
label = "Success action"
icon = "check"
on = "processed"
def process(self):
self.log.info("I am only available on a successful plug-in")
class FailedAction(pyblish.api.Action):
label = "Failure action"
icon = "close"
on = "failed"
class SucceededAction(pyblish.api.Action):
label = "Success action"
icon = "check"
on = "succeeded"
def process(self):
self.log.info("I am only available on a successful plug-in")
class LongLabelAction(pyblish.api.Action):
label = "An incredibly, incredicly looooon label. Very long."
icon = "close"
class BadEventAction(pyblish.api.Action):
label = "Bad event action"
on = "not exist"
class InactiveAction(pyblish.api.Action):
active = False
class PluginWithActions(pyblish.api.Validator):
optional = True
actions = [
pyblish.api.Category("General"),
ContextAction,
FailingAction,
LongRunningAction,
IconAction,
PluginAction,
pyblish.api.Category("Empty"),
pyblish.api.Category("OS"),
LaunchExplorerAction,
pyblish.api.Separator,
FailedAction,
SucceededAction,
pyblish.api.Category("Debug"),
BadEventAction,
InactiveAction,
LongLabelAction,
pyblish.api.Category("Empty"),
]
def process(self):
self.log.info("Ran PluginWithActions")
class FailingPluginWithActions(pyblish.api.Validator):
optional = True
actions = [
FailedAction,
SucceededAction,
]
def process(self):
raise Exception("I was programmed to fail")
class ValidateDefaultOff(pyblish.api.Validator):
families = ["A", "B"]
active = False
optional = True
def process(self, instance):
self.log.info("Processing instance..")
class ValidateWithHyperlinks(pyblish.api.Validator):
"""To learn about Pyblish
<a href="http://pyblish.com">click here</a> (http://pyblish.com)
"""
families = ["A", "B"]
def process(self, instance):
self.log.info("Processing instance..")
msg = "To learn about Pyblish, <a href='http://pyblish.com'>"
msg += "click here</a> (http://pyblish.com)"
self.log.info(msg)
class LongRunningCollector(pyblish.api.Collector):
"""I will take at least 2 seconds..."""
def process(self, context):
self.log.info("Sleeping for 2 seconds..")
time.sleep(2)
self.log.info("Good morning")
class LongRunningValidator(pyblish.api.Validator):
"""I will take at least 2 seconds..."""
def process(self, context):
self.log.info("Sleeping for 2 seconds..")
time.sleep(2)
self.log.info("Good morning")
class RearrangingPlugin(pyblish.api.ContextPlugin):
"""Sort plug-ins by family, and then reverse it"""
order = pyblish.api.CollectorOrder + 0.2
def process(self, context):
self.log.info("Reversing instances in the context..")
context[:] = sorted(
context,
key=lambda i: i.data["family"],
reverse=True
)
self.log.info("Reversed!")
class InactiveInstanceCollectorPlugin(pyblish.api.InstancePlugin):
"""Special case of an InstancePlugin running as a Collector"""
order = pyblish.api.CollectorOrder + 0.1
active = False
def process(self, instance):
raise TypeError("I shouldn't have run in the first place")
class CollectWithIcon(pyblish.api.ContextPlugin):
order = pyblish.api.CollectorOrder
def process(self, context):
instance = context.create_instance("With Icon")
instance.data["icon"] = "play"
instances = [
{
"name": "Peter01",
"data": {
"family": "A",
"publish": False
}
},
{
"name": "Richard05",
"data": {
"family": "A",
}
},
{
"name": "Steven11",
"data": {
"family": "B",
}
},
{
"name": "Piraya12",
"data": {
"family": "B",
}
},
{
"name": "Marcus",
"data": {
"family": "C",
}
},
{
"name": "Extra1",
"data": {
"family": "C",
}
},
{
"name": "DependencyInstance",
"data": {
"family": "diFamily"
}
},
{
"name": "NoFamily",
"data": {}
},
{
"name": "Failure 1",
"data": {
"family": "failure",
"fail": False
}
},
{
"name": "Failure 2",
"data": {
"family": "failure",
"fail": True
}
}
]
plugins = [
MyCollector,
MyValidator,
MyExtractor,
CollectRenamed,
CollectNegatron,
CollectPositron,
SelectInstances,
SelectInstances2,
SelectDiInstances,
SelectInstancesFailure,
ValidateFailureMock,
ValidateNamespace,
# ValidateIsIncompatible,
ValidateWithVeryVeryVeryLongLongNaaaaame,
ValidateContext,
ValidateContextFailure,
Validator1,
Validator2,
Validator3,
ValidateWithRepair,
ValidateWithRepairFailure,
ValidateWithRepairContext,
ValidateWithLabel,
ValidateWithLongLabel,
ValidateDefaultOff,
ValidateWithHyperlinks,
ExtractAsMa,
ConformAsset,
SimplePlugin1,
SimplePlugin2,
SimplePlugin3,
ValidateInstancesDI,
ExtractInstancesDI,
ValidateDIWithRepair,
PluginWithActions,
FailingPluginWithActions,
# LongRunningCollector,
# LongRunningValidator,
RearrangingPlugin,
InactiveInstanceCollectorPlugin,
CollectComment,
CollectWithIcon,
]
pyblish.api.sort_plugins(plugins)

File diff suppressed because it is too large Load diff

View file

@ -1,30 +0,0 @@
from .util import env_variable_to_bool
# Customize the window of the pyblish-lite window.
WindowTitle = "Pyblish"
# Customize whether to show label names for plugins.
UseLabel = True
# Customize which tab to start on. Possible choices are: "artist", "overview"
# and "terminal".
InitialTab = "overview"
# Customize the window size.
WindowSize = (430, 600)
TerminalFilters = {
"info": True,
"log_debug": True,
"log_info": True,
"log_warning": True,
"log_error": True,
"log_critical": True,
"traceback": True,
}
# Allow animations in GUI
Animated = env_variable_to_bool("AYON_PYBLISH_ANIMATED", True)
# Print UI info message to console
PrintInfo = env_variable_to_bool("AYON_PYBLISH_PRINT_INFO", True)

View file

@ -1,145 +0,0 @@
from __future__ import (
absolute_import,
division,
print_function,
unicode_literals
)
import os
import sys
import collections
from qtpy import QtCore
from six import text_type
import pyblish.api
root = os.path.dirname(__file__)
def get_asset(*path):
"""Return path to asset, relative the install directory
Usage:
>>> path = get_asset("dir", "to", "asset.png")
>>> path == os.path.join(root, "dir", "to", "asset.png")
True
Arguments:
path (str): One or more paths, to be concatenated
"""
return os.path.join(root, *path)
def defer(delay, func):
"""Append artificial delay to `func`
This aids in keeping the GUI responsive, but complicates logic
when producing tests. To combat this, the environment variable ensures
that every operation is synchronous.
Arguments:
delay (float): Delay multiplier; default 1, 0 means no delay
func (callable): Any callable
"""
delay *= float(os.getenv("PYBLISH_DELAY", 1))
if delay > 0:
return QtCore.QTimer.singleShot(delay, func)
else:
return func()
def u_print(msg, **kwargs):
"""`print` with encoded unicode.
`print` unicode may cause UnicodeEncodeError
or non-readable result when `PYTHONIOENCODING` is not set.
this will fix it.
Arguments:
msg (unicode): Message to print.
**kwargs: Keyword argument for `print` function.
"""
if isinstance(msg, text_type):
encoding = None
try:
encoding = os.getenv('PYTHONIOENCODING', sys.stdout.encoding)
except AttributeError:
# `sys.stdout.encoding` may not exists.
pass
msg = msg.encode(encoding or 'utf-8', 'replace')
print(msg, **kwargs)
def collect_families_from_instances(instances, only_active=False):
all_families = set()
for instance in instances:
if only_active:
if instance.data.get("publish") is False:
continue
family = instance.data.get("family")
if family:
all_families.add(family)
families = instance.data.get("families") or tuple()
for family in families:
all_families.add(family)
return list(all_families)
class OrderGroups:
validation_order = pyblish.api.ValidatorOrder + 0.5
groups = collections.OrderedDict((
(
pyblish.api.CollectorOrder + 0.5,
{
"label": "Collect",
"state": "Collecting.."
}
),
(
pyblish.api.ValidatorOrder + 0.5,
{
"label": "Validate",
"state": "Validating.."
}
),
(
pyblish.api.ExtractorOrder + 0.5,
{
"label": "Extract",
"state": "Extracting.."
}
),
(
pyblish.api.IntegratorOrder + 0.5,
{
"label": "Integrate",
"state": "Integrating.."
}
),
(
None,
{
"label": "Other",
"state": "Finishing.."
}
)
))
def env_variable_to_bool(env_key, default=False):
"""Boolean based on environment variable value."""
value = os.environ.get(env_key)
if value is not None:
value = value.lower()
if value in ("true", "1", "yes", "on"):
return True
elif value in ("false", "0", "no", "off"):
return False
return default

View file

@ -1,39 +0,0 @@
"""
qtawesome - use font-awesome in PyQt / PySide applications
This is a port to Python of the C++ QtAwesome library by Rick Blommers
"""
from .iconic_font import IconicFont, set_global_defaults
from .animation import Pulse, Spin
from ._version import version_info, __version__
_resource = {'iconic': None, }
def _instance():
if _resource['iconic'] is None:
_resource['iconic'] = IconicFont(('fa', 'fontawesome-webfont.ttf', 'fontawesome-webfont-charmap.json'),
('ei', 'elusiveicons-webfont.ttf', 'elusiveicons-webfont-charmap.json'))
return _resource['iconic']
def icon(*args, **kwargs):
return _instance().icon(*args, **kwargs)
def load_font(*args, **kwargs):
return _instance().load_font(*args, **kwargs)
def charmap(prefixed_name):
prefix, name = prefixed_name.split('.')
return _instance().charmap[prefix][name]
def font(*args, **kwargs):
return _instance().font(*args, **kwargs)
def set_defaults(**kwargs):
return set_global_defaults(**kwargs)

View file

@ -1,2 +0,0 @@
version_info = (0, 3, 0, 'dev')
__version__ = '.'.join(map(str, version_info))

View file

@ -1,41 +0,0 @@
from qtpy import QtCore
class Spin:
def __init__(self, parent_widget, interval=10, step=1):
self.parent_widget = parent_widget
self.interval, self.step = interval, step
self.info = {}
def _update(self, parent_widget):
if self.parent_widget in self.info:
timer, angle, step = self.info[self.parent_widget]
if angle >= 360:
angle = 0
angle += step
self.info[parent_widget] = timer, angle, step
parent_widget.update()
def setup(self, icon_painter, painter, rect):
if self.parent_widget not in self.info:
timer = QtCore.QTimer()
timer.timeout.connect(lambda: self._update(self.parent_widget))
self.info[self.parent_widget] = [timer, 0, self.step]
timer.start(self.interval)
else:
timer, angle, self.step = self.info[self.parent_widget]
x_center = rect.width() * 0.5
y_center = rect.height() * 0.5
painter.translate(x_center, y_center)
painter.rotate(angle)
painter.translate(-x_center, -y_center)
class Pulse(Spin):
def __init__(self, parent_widget):
Spin.__init__(self, parent_widget, interval=300, step=45)

View file

@ -1,306 +0,0 @@
{
"address-book": "0xf102",
"address-book-alt": "0xf101",
"adjust": "0xf104",
"adjust-alt": "0xf103",
"adult": "0xf105",
"align-center": "0xf106",
"align-justify": "0xf107",
"align-left": "0xf108",
"align-right": "0xf109",
"arrow-down": "0xf10a",
"arrow-left": "0xf10b",
"arrow-right": "0xf10c",
"arrow-up": "0xf10d",
"asl": "0xf10e",
"asterisk": "0xf10f",
"backward": "0xf110",
"ban-circle": "0xf111",
"barcode": "0xf112",
"behance": "0xf113",
"bell": "0xf114",
"blind": "0xf115",
"blogger": "0xf116",
"bold": "0xf117",
"book": "0xf118",
"bookmark": "0xf11a",
"bookmark-empty": "0xf119",
"braille": "0xf11b",
"briefcase": "0xf11c",
"broom": "0xf11d",
"brush": "0xf11e",
"bulb": "0xf11f",
"bullhorn": "0xf120",
"calendar": "0xf122",
"calendar-sign": "0xf121",
"camera": "0xf123",
"car": "0xf124",
"caret-down": "0xf125",
"caret-left": "0xf126",
"caret-right": "0xf127",
"caret-up": "0xf128",
"cc": "0xf129",
"certificate": "0xf12a",
"check": "0xf12c",
"check-empty": "0xf12b",
"chevron-down": "0xf12d",
"chevron-left": "0xf12e",
"chevron-right": "0xf12f",
"chevron-up": "0xf130",
"child": "0xf131",
"circle-arrow-down": "0xf132",
"circle-arrow-left": "0xf133",
"circle-arrow-right": "0xf134",
"circle-arrow-up": "0xf135",
"cloud": "0xf137",
"cloud-alt": "0xf136",
"cog": "0xf139",
"cog-alt": "0xf138",
"cogs": "0xf13a",
"comment": "0xf13c",
"comment-alt": "0xf13b",
"compass": "0xf13e",
"compass-alt": "0xf13d",
"credit-card": "0xf13f",
"css": "0xf140",
"dashboard": "0xf141",
"delicious": "0xf142",
"deviantart": "0xf143",
"digg": "0xf144",
"download": "0xf146",
"download-alt": "0xf145",
"dribbble": "0xf147",
"edit": "0xf148",
"eject": "0xf149",
"envelope": "0xf14b",
"envelope-alt": "0xf14a",
"error": "0xf14d",
"error-alt": "0xf14c",
"eur": "0xf14e",
"exclamation-sign": "0xf14f",
"eye-close": "0xf150",
"eye-open": "0xf151",
"facebook": "0xf152",
"facetime-video": "0xf153",
"fast-backward": "0xf154",
"fast-forward": "0xf155",
"female": "0xf156",
"file": "0xf15c",
"file-alt": "0xf157",
"file-edit": "0xf159",
"file-edit-alt": "0xf158",
"file-new": "0xf15b",
"file-new-alt": "0xf15a",
"film": "0xf15d",
"filter": "0xf15e",
"fire": "0xf15f",
"flag": "0xf161",
"flag-alt": "0xf160",
"flickr": "0xf162",
"folder": "0xf166",
"folder-close": "0xf163",
"folder-open": "0xf164",
"folder-sign": "0xf165",
"font": "0xf167",
"fontsize": "0xf168",
"fork": "0xf169",
"forward": "0xf16b",
"forward-alt": "0xf16a",
"foursquare": "0xf16c",
"friendfeed": "0xf16e",
"friendfeed-rect": "0xf16d",
"fullscreen": "0xf16f",
"gbp": "0xf170",
"gift": "0xf171",
"github": "0xf173",
"github-text": "0xf172",
"glass": "0xf174",
"glasses": "0xf175",
"globe": "0xf177",
"globe-alt": "0xf176",
"googleplus": "0xf178",
"graph": "0xf17a",
"graph-alt": "0xf179",
"group": "0xf17c",
"group-alt": "0xf17b",
"guidedog": "0xf17d",
"hand-down": "0xf17e",
"hand-left": "0xf17f",
"hand-right": "0xf180",
"hand-up": "0xf181",
"hdd": "0xf182",
"headphones": "0xf183",
"hearing-impaired": "0xf184",
"heart": "0xf187",
"heart-alt": "0xf185",
"heart-empty": "0xf186",
"home": "0xf189",
"home-alt": "0xf188",
"hourglass": "0xf18a",
"idea": "0xf18c",
"idea-alt": "0xf18b",
"inbox": "0xf18f",
"inbox-alt": "0xf18d",
"inbox-box": "0xf18e",
"indent-left": "0xf190",
"indent-right": "0xf191",
"info-circle": "0xf192",
"instagram": "0xf193",
"iphone-home": "0xf194",
"italic": "0xf195",
"key": "0xf196",
"laptop": "0xf198",
"laptop-alt": "0xf197",
"lastfm": "0xf199",
"leaf": "0xf19a",
"lines": "0xf19b",
"link": "0xf19c",
"linkedin": "0xf19d",
"list": "0xf19f",
"list-alt": "0xf19e",
"livejournal": "0xf1a0",
"lock": "0xf1a2",
"lock-alt": "0xf1a1",
"magic": "0xf1a3",
"magnet": "0xf1a4",
"male": "0xf1a5",
"map-marker": "0xf1a7",
"map-marker-alt": "0xf1a6",
"mic": "0xf1a9",
"mic-alt": "0xf1a8",
"minus": "0xf1ab",
"minus-sign": "0xf1aa",
"move": "0xf1ac",
"music": "0xf1ad",
"myspace": "0xf1ae",
"network": "0xf1af",
"off": "0xf1b0",
"ok": "0xf1b3",
"ok-circle": "0xf1b1",
"ok-sign": "0xf1b2",
"opensource": "0xf1b4",
"paper-clip": "0xf1b6",
"paper-clip-alt": "0xf1b5",
"path": "0xf1b7",
"pause": "0xf1b9",
"pause-alt": "0xf1b8",
"pencil": "0xf1bb",
"pencil-alt": "0xf1ba",
"person": "0xf1bc",
"phone": "0xf1be",
"phone-alt": "0xf1bd",
"photo": "0xf1c0",
"photo-alt": "0xf1bf",
"picasa": "0xf1c1",
"picture": "0xf1c2",
"pinterest": "0xf1c3",
"plane": "0xf1c4",
"play": "0xf1c7",
"play-alt": "0xf1c5",
"play-circle": "0xf1c6",
"plurk": "0xf1c9",
"plurk-alt": "0xf1c8",
"plus": "0xf1cb",
"plus-sign": "0xf1ca",
"podcast": "0xf1cc",
"print": "0xf1cd",
"puzzle": "0xf1ce",
"qrcode": "0xf1cf",
"question": "0xf1d1",
"question-sign": "0xf1d0",
"quote-alt": "0xf1d2",
"quote-right": "0xf1d4",
"quote-right-alt": "0xf1d3",
"quotes": "0xf1d5",
"random": "0xf1d6",
"record": "0xf1d7",
"reddit": "0xf1d8",
"redux": "0xf1d9",
"refresh": "0xf1da",
"remove": "0xf1dd",
"remove-circle": "0xf1db",
"remove-sign": "0xf1dc",
"repeat": "0xf1df",
"repeat-alt": "0xf1de",
"resize-full": "0xf1e0",
"resize-horizontal": "0xf1e1",
"resize-small": "0xf1e2",
"resize-vertical": "0xf1e3",
"return-key": "0xf1e4",
"retweet": "0xf1e5",
"reverse-alt": "0xf1e6",
"road": "0xf1e7",
"rss": "0xf1e8",
"scissors": "0xf1e9",
"screen": "0xf1eb",
"screen-alt": "0xf1ea",
"screenshot": "0xf1ec",
"search": "0xf1ee",
"search-alt": "0xf1ed",
"share": "0xf1f0",
"share-alt": "0xf1ef",
"shopping-cart": "0xf1f2",
"shopping-cart-sign": "0xf1f1",
"signal": "0xf1f3",
"skype": "0xf1f4",
"slideshare": "0xf1f5",
"smiley": "0xf1f7",
"smiley-alt": "0xf1f6",
"soundcloud": "0xf1f8",
"speaker": "0xf1f9",
"spotify": "0xf1fa",
"stackoverflow": "0xf1fb",
"star": "0xf1fe",
"star-alt": "0xf1fc",
"star-empty": "0xf1fd",
"step-backward": "0xf1ff",
"step-forward": "0xf200",
"stop": "0xf202",
"stop-alt": "0xf201",
"stumbleupon": "0xf203",
"tag": "0xf204",
"tags": "0xf205",
"tasks": "0xf206",
"text-height": "0xf207",
"text-width": "0xf208",
"th": "0xf20b",
"th-large": "0xf209",
"th-list": "0xf20a",
"thumbs-down": "0xf20c",
"thumbs-up": "0xf20d",
"time": "0xf20f",
"time-alt": "0xf20e",
"tint": "0xf210",
"torso": "0xf211",
"trash": "0xf213",
"trash-alt": "0xf212",
"tumblr": "0xf214",
"twitter": "0xf215",
"universal-access": "0xf216",
"unlock": "0xf218",
"unlock-alt": "0xf217",
"upload": "0xf219",
"usd": "0xf21a",
"user": "0xf21b",
"viadeo": "0xf21c",
"video": "0xf21f",
"video-alt": "0xf21d",
"video-chat": "0xf21e",
"view-mode": "0xf220",
"vimeo": "0xf221",
"vkontakte": "0xf222",
"volume-down": "0xf223",
"volume-off": "0xf224",
"volume-up": "0xf225",
"w3c": "0xf226",
"warning-sign": "0xf227",
"website": "0xf229",
"website-alt": "0xf228",
"wheelchair": "0xf22a",
"wordpress": "0xf22b",
"wrench": "0xf22d",
"wrench-alt": "0xf22c",
"youtube": "0xf22e",
"zoom-in": "0xf22f",
"zoom-out": "0xf230"
}

View file

@ -1,696 +0,0 @@
{
"500px": "f26e",
"adjust": "f042",
"adn": "f170",
"align-center": "f037",
"align-justify": "f039",
"align-left": "f036",
"align-right": "f038",
"amazon": "f270",
"ambulance": "f0f9",
"anchor": "f13d",
"android": "f17b",
"angellist": "f209",
"angle-double-down": "f103",
"angle-double-left": "f100",
"angle-double-right": "f101",
"angle-double-up": "f102",
"angle-down": "f107",
"angle-left": "f104",
"angle-right": "f105",
"angle-up": "f106",
"apple": "f179",
"archive": "f187",
"area-chart": "f1fe",
"arrow-circle-down": "f0ab",
"arrow-circle-left": "f0a8",
"arrow-circle-o-down": "f01a",
"arrow-circle-o-left": "f190",
"arrow-circle-o-right": "f18e",
"arrow-circle-o-up": "f01b",
"arrow-circle-right": "f0a9",
"arrow-circle-up": "f0aa",
"arrow-down": "f063",
"arrow-left": "f060",
"arrow-right": "f061",
"arrow-up": "f062",
"arrows": "f047",
"arrows-alt": "f0b2",
"arrows-h": "f07e",
"arrows-v": "f07d",
"asterisk": "f069",
"at": "f1fa",
"automobile": "f1b9",
"backward": "f04a",
"balance-scale": "f24e",
"ban": "f05e",
"bank": "f19c",
"bar-chart": "f080",
"bar-chart-o": "f080",
"barcode": "f02a",
"bars": "f0c9",
"battery-0": "f244",
"battery-1": "f243",
"battery-2": "f242",
"battery-3": "f241",
"battery-4": "f240",
"battery-empty": "f244",
"battery-full": "f240",
"battery-half": "f242",
"battery-quarter": "f243",
"battery-three-quarters": "f241",
"bed": "f236",
"beer": "f0fc",
"behance": "f1b4",
"behance-square": "f1b5",
"bell": "f0f3",
"bell-o": "f0a2",
"bell-slash": "f1f6",
"bell-slash-o": "f1f7",
"bicycle": "f206",
"binoculars": "f1e5",
"birthday-cake": "f1fd",
"bitbucket": "f171",
"bitbucket-square": "f172",
"bitcoin": "f15a",
"black-tie": "f27e",
"bluetooth": "f293",
"bluetooth-b": "f294",
"bold": "f032",
"bolt": "f0e7",
"bomb": "f1e2",
"book": "f02d",
"bookmark": "f02e",
"bookmark-o": "f097",
"briefcase": "f0b1",
"btc": "f15a",
"bug": "f188",
"building": "f1ad",
"building-o": "f0f7",
"bullhorn": "f0a1",
"bullseye": "f140",
"bus": "f207",
"buysellads": "f20d",
"cab": "f1ba",
"calculator": "f1ec",
"calendar": "f073",
"calendar-check-o": "f274",
"calendar-minus-o": "f272",
"calendar-o": "f133",
"calendar-plus-o": "f271",
"calendar-times-o": "f273",
"camera": "f030",
"camera-retro": "f083",
"car": "f1b9",
"caret-down": "f0d7",
"caret-left": "f0d9",
"caret-right": "f0da",
"caret-square-o-down": "f150",
"caret-square-o-left": "f191",
"caret-square-o-right": "f152",
"caret-square-o-up": "f151",
"caret-up": "f0d8",
"cart-arrow-down": "f218",
"cart-plus": "f217",
"cc": "f20a",
"cc-amex": "f1f3",
"cc-diners-club": "f24c",
"cc-discover": "f1f2",
"cc-jcb": "f24b",
"cc-mastercard": "f1f1",
"cc-paypal": "f1f4",
"cc-stripe": "f1f5",
"cc-visa": "f1f0",
"certificate": "f0a3",
"chain": "f0c1",
"chain-broken": "f127",
"check": "f00c",
"check-circle": "f058",
"check-circle-o": "f05d",
"check-square": "f14a",
"check-square-o": "f046",
"chevron-circle-down": "f13a",
"chevron-circle-left": "f137",
"chevron-circle-right": "f138",
"chevron-circle-up": "f139",
"chevron-down": "f078",
"chevron-left": "f053",
"chevron-right": "f054",
"chevron-up": "f077",
"child": "f1ae",
"chrome": "f268",
"circle": "f111",
"circle-o": "f10c",
"circle-o-notch": "f1ce",
"circle-thin": "f1db",
"clipboard": "f0ea",
"clock-o": "f017",
"clone": "f24d",
"close": "f00d",
"cloud": "f0c2",
"cloud-download": "f0ed",
"cloud-upload": "f0ee",
"cny": "f157",
"code": "f121",
"code-fork": "f126",
"codepen": "f1cb",
"codiepie": "f284",
"coffee": "f0f4",
"cog": "f013",
"cogs": "f085",
"columns": "f0db",
"comment": "f075",
"comment-o": "f0e5",
"commenting": "f27a",
"commenting-o": "f27b",
"comments": "f086",
"comments-o": "f0e6",
"compass": "f14e",
"compress": "f066",
"connectdevelop": "f20e",
"contao": "f26d",
"copy": "f0c5",
"copyright": "f1f9",
"creative-commons": "f25e",
"credit-card": "f09d",
"credit-card-alt": "f283",
"crop": "f125",
"crosshairs": "f05b",
"css3": "f13c",
"cube": "f1b2",
"cubes": "f1b3",
"cut": "f0c4",
"cutlery": "f0f5",
"dashboard": "f0e4",
"dashcube": "f210",
"database": "f1c0",
"dedent": "f03b",
"delicious": "f1a5",
"desktop": "f108",
"deviantart": "f1bd",
"diamond": "f219",
"digg": "f1a6",
"dollar": "f155",
"dot-circle-o": "f192",
"download": "f019",
"dribbble": "f17d",
"dropbox": "f16b",
"drupal": "f1a9",
"edge": "f282",
"edit": "f044",
"eject": "f052",
"ellipsis-h": "f141",
"ellipsis-v": "f142",
"empire": "f1d1",
"envelope": "f0e0",
"envelope-o": "f003",
"envelope-square": "f199",
"eraser": "f12d",
"eur": "f153",
"euro": "f153",
"exchange": "f0ec",
"exclamation": "f12a",
"exclamation-circle": "f06a",
"exclamation-triangle": "f071",
"expand": "f065",
"expeditedssl": "f23e",
"external-link": "f08e",
"external-link-square": "f14c",
"eye": "f06e",
"eye-slash": "f070",
"eyedropper": "f1fb",
"facebook": "f09a",
"facebook-f": "f09a",
"facebook-official": "f230",
"facebook-square": "f082",
"fast-backward": "f049",
"fast-forward": "f050",
"fax": "f1ac",
"feed": "f09e",
"female": "f182",
"fighter-jet": "f0fb",
"file": "f15b",
"file-archive-o": "f1c6",
"file-audio-o": "f1c7",
"file-code-o": "f1c9",
"file-excel-o": "f1c3",
"file-image-o": "f1c5",
"file-movie-o": "f1c8",
"file-o": "f016",
"file-pdf-o": "f1c1",
"file-photo-o": "f1c5",
"file-picture-o": "f1c5",
"file-powerpoint-o": "f1c4",
"file-sound-o": "f1c7",
"file-text": "f15c",
"file-text-o": "f0f6",
"file-video-o": "f1c8",
"file-word-o": "f1c2",
"file-zip-o": "f1c6",
"files-o": "f0c5",
"film": "f008",
"filter": "f0b0",
"fire": "f06d",
"fire-extinguisher": "f134",
"firefox": "f269",
"flag": "f024",
"flag-checkered": "f11e",
"flag-o": "f11d",
"flash": "f0e7",
"flask": "f0c3",
"flickr": "f16e",
"floppy-o": "f0c7",
"folder": "f07b",
"folder-o": "f114",
"folder-open": "f07c",
"folder-open-o": "f115",
"font": "f031",
"fonticons": "f280",
"fort-awesome": "f286",
"forumbee": "f211",
"forward": "f04e",
"foursquare": "f180",
"frown-o": "f119",
"futbol-o": "f1e3",
"gamepad": "f11b",
"gavel": "f0e3",
"gbp": "f154",
"ge": "f1d1",
"gear": "f013",
"gears": "f085",
"genderless": "f22d",
"get-pocket": "f265",
"gg": "f260",
"gg-circle": "f261",
"gift": "f06b",
"git": "f1d3",
"git-square": "f1d2",
"github": "f09b",
"github-alt": "f113",
"github-square": "f092",
"gittip": "f184",
"glass": "f000",
"globe": "f0ac",
"google": "f1a0",
"google-plus": "f0d5",
"google-plus-square": "f0d4",
"google-wallet": "f1ee",
"graduation-cap": "f19d",
"gratipay": "f184",
"group": "f0c0",
"h-square": "f0fd",
"hacker-news": "f1d4",
"hand-grab-o": "f255",
"hand-lizard-o": "f258",
"hand-o-down": "f0a7",
"hand-o-left": "f0a5",
"hand-o-right": "f0a4",
"hand-o-up": "f0a6",
"hand-paper-o": "f256",
"hand-peace-o": "f25b",
"hand-pointer-o": "f25a",
"hand-rock-o": "f255",
"hand-scissors-o": "f257",
"hand-spock-o": "f259",
"hand-stop-o": "f256",
"hashtag": "f292",
"hdd-o": "f0a0",
"header": "f1dc",
"headphones": "f025",
"heart": "f004",
"heart-o": "f08a",
"heartbeat": "f21e",
"history": "f1da",
"home": "f015",
"hospital-o": "f0f8",
"hotel": "f236",
"hourglass": "f254",
"hourglass-1": "f251",
"hourglass-2": "f252",
"hourglass-3": "f253",
"hourglass-end": "f253",
"hourglass-half": "f252",
"hourglass-o": "f250",
"hourglass-start": "f251",
"houzz": "f27c",
"html5": "f13b",
"i-cursor": "f246",
"ils": "f20b",
"image": "f03e",
"inbox": "f01c",
"indent": "f03c",
"industry": "f275",
"info": "f129",
"info-circle": "f05a",
"inr": "f156",
"instagram": "f16d",
"institution": "f19c",
"internet-explorer": "f26b",
"intersex": "f224",
"ioxhost": "f208",
"italic": "f033",
"joomla": "f1aa",
"jpy": "f157",
"jsfiddle": "f1cc",
"key": "f084",
"keyboard-o": "f11c",
"krw": "f159",
"language": "f1ab",
"laptop": "f109",
"lastfm": "f202",
"lastfm-square": "f203",
"leaf": "f06c",
"leanpub": "f212",
"legal": "f0e3",
"lemon-o": "f094",
"level-down": "f149",
"level-up": "f148",
"life-bouy": "f1cd",
"life-buoy": "f1cd",
"life-ring": "f1cd",
"life-saver": "f1cd",
"lightbulb-o": "f0eb",
"line-chart": "f201",
"link": "f0c1",
"linkedin": "f0e1",
"linkedin-square": "f08c",
"linux": "f17c",
"list": "f03a",
"list-alt": "f022",
"list-ol": "f0cb",
"list-ul": "f0ca",
"location-arrow": "f124",
"lock": "f023",
"long-arrow-down": "f175",
"long-arrow-left": "f177",
"long-arrow-right": "f178",
"long-arrow-up": "f176",
"magic": "f0d0",
"magnet": "f076",
"mail-forward": "f064",
"mail-reply": "f112",
"mail-reply-all": "f122",
"male": "f183",
"map": "f279",
"map-marker": "f041",
"map-o": "f278",
"map-pin": "f276",
"map-signs": "f277",
"mars": "f222",
"mars-double": "f227",
"mars-stroke": "f229",
"mars-stroke-h": "f22b",
"mars-stroke-v": "f22a",
"maxcdn": "f136",
"meanpath": "f20c",
"medium": "f23a",
"medkit": "f0fa",
"meh-o": "f11a",
"mercury": "f223",
"microphone": "f130",
"microphone-slash": "f131",
"minus": "f068",
"minus-circle": "f056",
"minus-square": "f146",
"minus-square-o": "f147",
"mixcloud": "f289",
"mobile": "f10b",
"mobile-phone": "f10b",
"modx": "f285",
"money": "f0d6",
"moon-o": "f186",
"mortar-board": "f19d",
"motorcycle": "f21c",
"mouse-pointer": "f245",
"music": "f001",
"navicon": "f0c9",
"neuter": "f22c",
"newspaper-o": "f1ea",
"object-group": "f247",
"object-ungroup": "f248",
"odnoklassniki": "f263",
"odnoklassniki-square": "f264",
"opencart": "f23d",
"openid": "f19b",
"opera": "f26a",
"optin-monster": "f23c",
"outdent": "f03b",
"pagelines": "f18c",
"paint-brush": "f1fc",
"paper-plane": "f1d8",
"paper-plane-o": "f1d9",
"paperclip": "f0c6",
"paragraph": "f1dd",
"paste": "f0ea",
"pause": "f04c",
"pause-circle": "f28b",
"pause-circle-o": "f28c",
"paw": "f1b0",
"paypal": "f1ed",
"pencil": "f040",
"pencil-square": "f14b",
"pencil-square-o": "f044",
"percent": "f295",
"phone": "f095",
"phone-square": "f098",
"photo": "f03e",
"picture-o": "f03e",
"pie-chart": "f200",
"pied-piper": "f1a7",
"pied-piper-alt": "f1a8",
"pinterest": "f0d2",
"pinterest-p": "f231",
"pinterest-square": "f0d3",
"plane": "f072",
"play": "f04b",
"play-circle": "f144",
"play-circle-o": "f01d",
"plug": "f1e6",
"plus": "f067",
"plus-circle": "f055",
"plus-square": "f0fe",
"plus-square-o": "f196",
"power-off": "f011",
"print": "f02f",
"product-hunt": "f288",
"puzzle-piece": "f12e",
"qq": "f1d6",
"qrcode": "f029",
"question": "f128",
"question-circle": "f059",
"quote-left": "f10d",
"quote-right": "f10e",
"ra": "f1d0",
"random": "f074",
"rebel": "f1d0",
"recycle": "f1b8",
"reddit": "f1a1",
"reddit-alien": "f281",
"reddit-square": "f1a2",
"refresh": "f021",
"registered": "f25d",
"remove": "f00d",
"renren": "f18b",
"reorder": "f0c9",
"repeat": "f01e",
"reply": "f112",
"reply-all": "f122",
"retweet": "f079",
"rmb": "f157",
"road": "f018",
"rocket": "f135",
"rotate-left": "f0e2",
"rotate-right": "f01e",
"rouble": "f158",
"rss": "f09e",
"rss-square": "f143",
"rub": "f158",
"ruble": "f158",
"rupee": "f156",
"safari": "f267",
"save": "f0c7",
"scissors": "f0c4",
"scribd": "f28a",
"search": "f002",
"search-minus": "f010",
"search-plus": "f00e",
"sellsy": "f213",
"send": "f1d8",
"send-o": "f1d9",
"server": "f233",
"share": "f064",
"share-alt": "f1e0",
"share-alt-square": "f1e1",
"share-square": "f14d",
"share-square-o": "f045",
"shekel": "f20b",
"sheqel": "f20b",
"shield": "f132",
"ship": "f21a",
"shirtsinbulk": "f214",
"shopping-bag": "f290",
"shopping-basket": "f291",
"shopping-cart": "f07a",
"sign-in": "f090",
"sign-out": "f08b",
"signal": "f012",
"simplybuilt": "f215",
"sitemap": "f0e8",
"skyatlas": "f216",
"skype": "f17e",
"slack": "f198",
"sliders": "f1de",
"slideshare": "f1e7",
"smile-o": "f118",
"soccer-ball-o": "f1e3",
"sort": "f0dc",
"sort-alpha-asc": "f15d",
"sort-alpha-desc": "f15e",
"sort-amount-asc": "f160",
"sort-amount-desc": "f161",
"sort-asc": "f0de",
"sort-desc": "f0dd",
"sort-down": "f0dd",
"sort-numeric-asc": "f162",
"sort-numeric-desc": "f163",
"sort-up": "f0de",
"soundcloud": "f1be",
"space-shuttle": "f197",
"spinner": "f110",
"spoon": "f1b1",
"spotify": "f1bc",
"square": "f0c8",
"square-o": "f096",
"stack-exchange": "f18d",
"stack-overflow": "f16c",
"star": "f005",
"star-half": "f089",
"star-half-empty": "f123",
"star-half-full": "f123",
"star-half-o": "f123",
"star-o": "f006",
"steam": "f1b6",
"steam-square": "f1b7",
"step-backward": "f048",
"step-forward": "f051",
"stethoscope": "f0f1",
"sticky-note": "f249",
"sticky-note-o": "f24a",
"stop": "f04d",
"stop-circle": "f28d",
"stop-circle-o": "f28e",
"street-view": "f21d",
"strikethrough": "f0cc",
"stumbleupon": "f1a4",
"stumbleupon-circle": "f1a3",
"subscript": "f12c",
"subway": "f239",
"suitcase": "f0f2",
"sun-o": "f185",
"superscript": "f12b",
"support": "f1cd",
"table": "f0ce",
"tablet": "f10a",
"tachometer": "f0e4",
"tag": "f02b",
"tags": "f02c",
"tasks": "f0ae",
"taxi": "f1ba",
"television": "f26c",
"tencent-weibo": "f1d5",
"terminal": "f120",
"text-height": "f034",
"text-width": "f035",
"th": "f00a",
"th-large": "f009",
"th-list": "f00b",
"thumb-tack": "f08d",
"thumbs-down": "f165",
"thumbs-o-down": "f088",
"thumbs-o-up": "f087",
"thumbs-up": "f164",
"ticket": "f145",
"times": "f00d",
"times-circle": "f057",
"times-circle-o": "f05c",
"tint": "f043",
"toggle-down": "f150",
"toggle-left": "f191",
"toggle-off": "f204",
"toggle-on": "f205",
"toggle-right": "f152",
"toggle-up": "f151",
"trademark": "f25c",
"train": "f238",
"transgender": "f224",
"transgender-alt": "f225",
"trash": "f1f8",
"trash-o": "f014",
"tree": "f1bb",
"trello": "f181",
"tripadvisor": "f262",
"trophy": "f091",
"truck": "f0d1",
"try": "f195",
"tty": "f1e4",
"tumblr": "f173",
"tumblr-square": "f174",
"turkish-lira": "f195",
"tv": "f26c",
"twitch": "f1e8",
"twitter": "f099",
"twitter-square": "f081",
"umbrella": "f0e9",
"underline": "f0cd",
"undo": "f0e2",
"university": "f19c",
"unlink": "f127",
"unlock": "f09c",
"unlock-alt": "f13e",
"unsorted": "f0dc",
"upload": "f093",
"usb": "f287",
"usd": "f155",
"user": "f007",
"user-md": "f0f0",
"user-plus": "f234",
"user-secret": "f21b",
"user-times": "f235",
"users": "f0c0",
"venus": "f221",
"venus-double": "f226",
"venus-mars": "f228",
"viacoin": "f237",
"video-camera": "f03d",
"vimeo": "f27d",
"vimeo-square": "f194",
"vine": "f1ca",
"vk": "f189",
"volume-down": "f027",
"volume-off": "f026",
"volume-up": "f028",
"warning": "f071",
"wechat": "f1d7",
"weibo": "f18a",
"weixin": "f1d7",
"whatsapp": "f232",
"wheelchair": "f193",
"wifi": "f1eb",
"wikipedia-w": "f266",
"windows": "f17a",
"won": "f159",
"wordpress": "f19a",
"wrench": "f0ad",
"xing": "f168",
"xing-square": "f169",
"y-combinator": "f23b",
"y-combinator-square": "f1d4",
"yahoo": "f19e",
"yc": "f23b",
"yc-square": "f1d4",
"yelp": "f1e9",
"yen": "f157",
"youtube": "f167",
"youtube-play": "f16a",
"youtube-square": "f166"
}

View file

@ -1,287 +0,0 @@
"""Classes handling iconic fonts"""
from __future__ import print_function
import json
import os
import six
from qtpy import QtCore, QtGui
_default_options = {
'color': QtGui.QColor(50, 50, 50),
'color_disabled': QtGui.QColor(150, 150, 150),
'opacity': 1.0,
'scale_factor': 1.0,
}
def set_global_defaults(**kwargs):
"""Set global defaults for all icons"""
valid_options = ['active', 'animation', 'color', 'color_active',
'color_disabled', 'color_selected', 'disabled', 'offset',
'scale_factor', 'selected']
for kw in kwargs:
if kw in valid_options:
_default_options[kw] = kwargs[kw]
else:
error = "Invalid option '{0}'".format(kw)
raise KeyError(error)
class CharIconPainter:
"""Char icon painter"""
def paint(self, iconic, painter, rect, mode, state, options):
"""Main paint method"""
for opt in options:
self._paint_icon(iconic, painter, rect, mode, state, opt)
def _paint_icon(self, iconic, painter, rect, mode, state, options):
"""Paint a single icon"""
painter.save()
color, char = options['color'], options['char']
if mode == QtGui.QIcon.Disabled:
color = options.get('color_disabled', color)
char = options.get('disabled', char)
elif mode == QtGui.QIcon.Active:
color = options.get('color_active', color)
char = options.get('active', char)
elif mode == QtGui.QIcon.Selected:
color = options.get('color_selected', color)
char = options.get('selected', char)
painter.setPen(QtGui.QColor(color))
# A 16 pixel-high icon yields a font size of 14, which is pixel perfect
# for font-awesome. 16 * 0.875 = 14
# The reason for not using full-sized glyphs is the negative bearing of
# fonts.
draw_size = 0.875 * round(rect.height() * options['scale_factor'])
prefix = options['prefix']
# Animation setup hook
animation = options.get('animation')
if animation is not None:
animation.setup(self, painter, rect)
painter.setFont(iconic.font(prefix, draw_size))
if 'offset' in options:
rect = QtCore.QRect(rect)
rect.translate(options['offset'][0] * rect.width(),
options['offset'][1] * rect.height())
painter.setOpacity(options.get('opacity', 1.0))
painter.drawText(rect,
QtCore.Qt.AlignCenter | QtCore.Qt.AlignVCenter,
char)
painter.restore()
class CharIconEngine(QtGui.QIconEngine):
"""Specialization of QtGui.QIconEngine used to draw font-based icons"""
def __init__(self, iconic, painter, options):
super(CharIconEngine, self).__init__()
self.iconic = iconic
self.painter = painter
self.options = options
def paint(self, painter, rect, mode, state):
self.painter.paint(
self.iconic, painter, rect, mode, state, self.options)
def pixmap(self, size, mode, state):
pm = QtGui.QPixmap(size)
pm.fill(QtCore.Qt.transparent)
self.paint(QtGui.QPainter(pm),
QtCore.QRect(QtCore.QPoint(0, 0), size),
mode,
state)
return pm
class IconicFont(QtCore.QObject):
"""Main class for managing iconic fonts"""
def __init__(self, *args):
"""Constructor
:param *args: tuples
Each positional argument is a tuple of 3 or 4 values
- The prefix string to be used when accessing a given font set
- The ttf font filename
- The json charmap filename
- Optionally, the directory containing these files. When not
provided, the files will be looked up in ./fonts/
"""
super(IconicFont, self).__init__()
self.painter = CharIconPainter()
self.painters = {}
self.fontname = {}
self.charmap = {}
for fargs in args:
self.load_font(*fargs)
def load_font(self,
prefix,
ttf_filename,
charmap_filename,
directory=None):
"""Loads a font file and the associated charmap
If `directory` is None, the files will be looked up in ./fonts/
Arguments
---------
prefix: str
prefix string to be used when accessing a given font set
ttf_filename: str
ttf font filename
charmap_filename: str
charmap filename
directory: str or None, optional
directory for font and charmap files
"""
def hook(obj):
result = {}
for key in obj:
result[key] = six.unichr(int(obj[key], 16))
return result
if directory is None:
directory = os.path.join(
os.path.dirname(os.path.realpath(__file__)), 'fonts')
with open(os.path.join(directory, charmap_filename), 'r') as codes:
self.charmap[prefix] = json.load(codes, object_hook=hook)
id_ = QtGui.QFontDatabase.addApplicationFont(
os.path.join(directory, ttf_filename))
loadedFontFamilies = QtGui.QFontDatabase.applicationFontFamilies(id_)
if(loadedFontFamilies):
self.fontname[prefix] = loadedFontFamilies[0]
else:
print('Font is empty')
def icon(self, *names, **kwargs):
"""Returns a QtGui.QIcon object corresponding to the provided icon name
(including prefix)
Arguments
---------
names: list of str
icon name, of the form PREFIX.NAME
options: dict
options to be passed to the icon painter
"""
options_list = kwargs.pop('options', [{}] * len(names))
general_options = kwargs
if len(options_list) != len(names):
error = '"options" must be a list of size {0}'.format(len(names))
raise Exception(error)
parsed_options = []
for i in range(len(options_list)):
specific_options = options_list[i]
parsed_options.append(self._parse_options(specific_options,
general_options,
names[i]))
# Process high level API
api_options = parsed_options
return self._icon_by_painter(self.painter, api_options)
def _parse_options(self, specific_options, general_options, name):
""" """
options = dict(_default_options, **general_options)
options.update(specific_options)
# Handle icons for states
icon_kw = ['disabled', 'active', 'selected', 'char']
names = [options.get(kw, name) for kw in icon_kw]
prefix, chars = self._get_prefix_chars(names)
options.update(dict(zip(*(icon_kw, chars))))
options.update({'prefix': prefix})
# Handle colors for states
color_kw = ['color_active', 'color_selected']
colors = [options.get(kw, options['color']) for kw in color_kw]
options.update(dict(zip(*(color_kw, colors))))
return options
def _get_prefix_chars(self, names):
""" """
chars = []
for name in names:
if '.' in name:
prefix, n = name.split('.')
if prefix in self.charmap:
if n in self.charmap[prefix]:
chars.append(self.charmap[prefix][n])
else:
error = 'Invalid icon name "{0}" in font "{1}"'.format(
n, prefix)
raise Exception(error)
else:
error = 'Invalid font prefix "{0}"'.format(prefix)
raise Exception(error)
else:
raise Exception('Invalid icon name')
return prefix, chars
def font(self, prefix, size):
"""Returns QtGui.QFont corresponding to the given prefix and size
Arguments
---------
prefix: str
prefix string of the loaded font
size: int
size for the font
"""
font = QtGui.QFont(self.fontname[prefix])
font.setPixelSize(size)
return font
def set_custom_icon(self, name, painter):
"""Associates a user-provided CharIconPainter to an icon name
The custom icon can later be addressed by calling
icon('custom.NAME') where NAME is the provided name for that icon.
Arguments
---------
name: str
name of the custom icon
painter: CharIconPainter
The icon painter, implementing
`paint(self, iconic, painter, rect, mode, state, options)`
"""
self.painters[name] = painter
def _custom_icon(self, name, **kwargs):
"""Returns the custom icon corresponding to the given name"""
options = dict(_default_options, **kwargs)
if name in self.painters:
painter = self.painters[name]
return self._icon_by_painter(painter, options)
else:
return QtGui.QIcon()
def _icon_by_painter(self, painter, options):
"""Returns the icon corresponding to the given painter"""
engine = CharIconEngine(self, painter, options)
return QtGui.QIcon(engine)

View file

@ -1,11 +0,0 @@
VERSION_MAJOR = 2
VERSION_MINOR = 9
VERSION_PATCH = 0
version_info = (VERSION_MAJOR, VERSION_MINOR, VERSION_PATCH)
version = '%i.%i.%i' % version_info
__version__ = version
__all__ = ['version', 'version_info', '__version__']

View file

@ -1,334 +0,0 @@
from qtpy import QtCore, QtWidgets
from . import model
from .constants import Roles, EXPANDER_WIDTH
# Imported when used
widgets = None
def _import_widgets():
global widgets
if widgets is None:
from . import widgets
class OverviewView(QtWidgets.QTreeView):
# An item is requesting to be toggled, with optional forced-state
toggled = QtCore.Signal(QtCore.QModelIndex, object)
show_perspective = QtCore.Signal(QtCore.QModelIndex)
def __init__(self, parent=None):
super(OverviewView, self).__init__(parent)
self.horizontalScrollBar().hide()
self.viewport().setAttribute(QtCore.Qt.WA_Hover, True)
self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
self.setItemsExpandable(True)
self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel)
self.setHeaderHidden(True)
self.setRootIsDecorated(False)
self.setIndentation(0)
def event(self, event):
if not event.type() == QtCore.QEvent.KeyPress:
return super(OverviewView, self).event(event)
elif event.key() == QtCore.Qt.Key_Space:
for index in self.selectionModel().selectedIndexes():
self.toggled.emit(index, None)
return True
elif event.key() == QtCore.Qt.Key_Backspace:
for index in self.selectionModel().selectedIndexes():
self.toggled.emit(index, False)
return True
elif event.key() == QtCore.Qt.Key_Return:
for index in self.selectionModel().selectedIndexes():
self.toggled.emit(index, True)
return True
return super(OverviewView, self).event(event)
def focusOutEvent(self, event):
self.selectionModel().clear()
def mouseReleaseEvent(self, event):
if event.button() in (QtCore.Qt.LeftButton, QtCore.Qt.RightButton):
# Deselect all group labels
indexes = self.selectionModel().selectedIndexes()
for index in indexes:
if index.data(Roles.TypeRole) == model.GroupType:
self.selectionModel().select(
index, QtCore.QItemSelectionModel.Deselect
)
return super(OverviewView, self).mouseReleaseEvent(event)
class PluginView(OverviewView):
def __init__(self, *args, **kwargs):
super(PluginView, self).__init__(*args, **kwargs)
self.clicked.connect(self.item_expand)
def item_expand(self, index):
if index.data(Roles.TypeRole) == model.GroupType:
if self.isExpanded(index):
self.collapse(index)
else:
self.expand(index)
def mouseReleaseEvent(self, event):
if event.button() == QtCore.Qt.LeftButton:
indexes = self.selectionModel().selectedIndexes()
if len(indexes) == 1:
index = indexes[0]
pos_index = self.indexAt(event.pos())
# If instance or Plugin and is selected
if (
index == pos_index
and index.data(Roles.TypeRole) == model.PluginType
):
if event.pos().x() < 20:
self.toggled.emit(index, None)
elif event.pos().x() > self.width() - 20:
self.show_perspective.emit(index)
return super(PluginView, self).mouseReleaseEvent(event)
class InstanceView(OverviewView):
def __init__(self, *args, **kwargs):
super(InstanceView, self).__init__(*args, **kwargs)
self.setSortingEnabled(True)
self.sortByColumn(0, QtCore.Qt.AscendingOrder)
self.viewport().setMouseTracking(True)
self._pressed_group_index = None
self._pressed_expander = None
def mouseMoveEvent(self, event):
index = self.indexAt(event.pos())
if index.data(Roles.TypeRole) == model.GroupType:
self.update(index)
super(InstanceView, self).mouseMoveEvent(event)
def item_expand(self, index, expand=None):
if expand is None:
expand = not self.isExpanded(index)
if expand:
self.expand(index)
else:
self.collapse(index)
def group_toggle(self, index):
if not index.isValid():
return
model = index.model()
chilren_indexes_checked = []
chilren_indexes_unchecked = []
for idx in range(model.rowCount(index)):
child_index = model.index(idx, 0, index)
if not child_index.data(Roles.IsEnabledRole):
continue
if child_index.data(QtCore.Qt.CheckStateRole):
chilren_indexes_checked.append(child_index)
else:
chilren_indexes_unchecked.append(child_index)
if chilren_indexes_checked:
to_change_indexes = chilren_indexes_checked
new_state = False
else:
to_change_indexes = chilren_indexes_unchecked
new_state = True
for index in to_change_indexes:
model.setData(index, new_state, QtCore.Qt.CheckStateRole)
self.toggled.emit(index, new_state)
def _mouse_press(self, event):
if event.button() != QtCore.Qt.LeftButton:
return
self._pressed_group_index = None
self._pressed_expander = None
pos_index = self.indexAt(event.pos())
if not pos_index.isValid():
return
if pos_index.data(Roles.TypeRole) != model.InstanceType:
self._pressed_group_index = pos_index
if event.pos().x() < 20:
self._pressed_expander = True
else:
self._pressed_expander = False
elif event.pos().x() < 20:
indexes = self.selectionModel().selectedIndexes()
any_checked = False
if len(indexes) <= 1:
return
if pos_index in indexes:
for index in indexes:
if index.data(QtCore.Qt.CheckStateRole):
any_checked = True
break
for index in indexes:
self.toggled.emit(index, not any_checked)
return True
self.toggled.emit(pos_index, not any_checked)
def mousePressEvent(self, event):
if self._mouse_press(event):
return
return super(InstanceView, self).mousePressEvent(event)
def _mouse_release(self, event, pressed_expander, pressed_index):
if event.button() != QtCore.Qt.LeftButton:
return
pos_index = self.indexAt(event.pos())
if not pos_index.isValid():
return
if pos_index.data(Roles.TypeRole) == model.InstanceType:
indexes = self.selectionModel().selectedIndexes()
if len(indexes) == 1 and indexes[0] == pos_index:
if event.pos().x() < 20:
self.toggled.emit(indexes[0], None)
elif event.pos().x() > self.width() - 20:
self.show_perspective.emit(indexes[0])
return True
return
if pressed_index != pos_index:
return
if self.state() == QtWidgets.QTreeView.State.DragSelectingState:
indexes = self.selectionModel().selectedIndexes()
if len(indexes) != 1 or indexes[0] != pos_index:
return
if event.pos().x() < EXPANDER_WIDTH:
if pressed_expander is True:
self.item_expand(pos_index)
return True
else:
if pressed_expander is False:
self.group_toggle(pos_index)
self.item_expand(pos_index, True)
return True
def mouseReleaseEvent(self, event):
pressed_index = self._pressed_group_index
pressed_expander = self._pressed_expander is True
self._pressed_group_index = None
self._pressed_expander = None
result = self._mouse_release(event, pressed_expander, pressed_index)
if result:
return
return super(InstanceView, self).mouseReleaseEvent(event)
class TerminalView(QtWidgets.QTreeView):
# An item is requesting to be toggled, with optional forced-state
def __init__(self, parent=None):
super(TerminalView, self).__init__(parent)
self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
self.setAutoScroll(False)
self.setHeaderHidden(True)
self.setIndentation(0)
self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel)
self.verticalScrollBar().setSingleStep(10)
self.setRootIsDecorated(False)
self.clicked.connect(self.item_expand)
_import_widgets()
def event(self, event):
if not event.type() == QtCore.QEvent.KeyPress:
return super(TerminalView, self).event(event)
elif event.key() == QtCore.Qt.Key_Space:
for index in self.selectionModel().selectedIndexes():
if self.isExpanded(index):
self.collapse(index)
else:
self.expand(index)
elif event.key() == QtCore.Qt.Key_Backspace:
for index in self.selectionModel().selectedIndexes():
self.collapse(index)
elif event.key() == QtCore.Qt.Key_Return:
for index in self.selectionModel().selectedIndexes():
self.expand(index)
return super(TerminalView, self).event(event)
def focusOutEvent(self, event):
self.selectionModel().clear()
def item_expand(self, index):
if index.data(Roles.TypeRole) == model.TerminalLabelType:
if self.isExpanded(index):
self.collapse(index)
else:
self.expand(index)
self.model().layoutChanged.emit()
self.updateGeometry()
def rowsInserted(self, parent, start, end):
"""Automatically scroll to bottom on each new item added."""
super(TerminalView, self).rowsInserted(parent, start, end)
self.updateGeometry()
self.scrollToBottom()
def expand(self, index):
"""Wrapper to set widget for expanded index."""
model = index.model()
row_count = model.rowCount(index)
is_new = False
for child_idx in range(row_count):
child_index = model.index(child_idx, index.column(), index)
widget = self.indexWidget(child_index)
if widget is None:
is_new = True
msg = child_index.data(QtCore.Qt.DisplayRole)
widget = widgets.TerminalDetail(msg)
self.setIndexWidget(child_index, widget)
super(TerminalView, self).expand(index)
if is_new:
self.updateGeometries()
def resizeEvent(self, event):
super(self.__class__, self).resizeEvent(event)
self.model().layoutChanged.emit()
def sizeHint(self):
size = super(TerminalView, self).sizeHint()
height = (
self.contentsMargins().top()
+ self.contentsMargins().bottom()
)
for idx_i in range(self.model().rowCount()):
index = self.model().index(idx_i, 0)
height += self.rowHeight(index)
if self.isExpanded(index):
for idx_j in range(index.model().rowCount(index)):
child_index = index.child(idx_j, 0)
height += self.rowHeight(child_index)
size.setHeight(height)
return size

View file

@ -1,555 +0,0 @@
import sys
from qtpy import QtCore, QtWidgets, QtGui
from . import model, delegate, view, awesome
from .constants import PluginStates, InstanceStates, Roles
class EllidableLabel(QtWidgets.QLabel):
def __init__(self, *args, **kwargs):
super(EllidableLabel, self).__init__(*args, **kwargs)
self.setObjectName("EllidableLabel")
def paintEvent(self, event):
painter = QtGui.QPainter(self)
metrics = QtGui.QFontMetrics(self.font())
elided = metrics.elidedText(
self.text(), QtCore.Qt.ElideRight, self.width()
)
painter.drawText(self.rect(), self.alignment(), elided)
class PerspectiveLabel(QtWidgets.QTextEdit):
def __init__(self, parent=None):
super(PerspectiveLabel, self).__init__(parent)
self.setObjectName("PerspectiveLabel")
size_policy = self.sizePolicy()
size_policy.setHeightForWidth(True)
size_policy.setVerticalPolicy(QtWidgets.QSizePolicy.Preferred)
self.setSizePolicy(size_policy)
self.textChanged.connect(self.on_text_changed)
def on_text_changed(self, *args, **kwargs):
self.updateGeometry()
def hasHeightForWidth(self):
return True
def heightForWidth(self, width):
margins = self.contentsMargins()
document_width = 0
if width >= margins.left() + margins.right():
document_width = width - margins.left() - margins.right()
document = self.document().clone()
document.setTextWidth(document_width)
return margins.top() + document.size().height() + margins.bottom()
def sizeHint(self):
width = super(PerspectiveLabel, self).sizeHint().width()
return QtCore.QSize(width, self.heightForWidth(width))
class PerspectiveWidget(QtWidgets.QWidget):
l_doc = "Documentation"
l_rec = "Records"
l_path = "Path"
def __init__(self, parent):
super(PerspectiveWidget, self).__init__(parent)
self.parent_widget = parent
main_layout = QtWidgets.QVBoxLayout(self)
header_widget = QtWidgets.QWidget()
toggle_button = QtWidgets.QPushButton(parent=header_widget)
toggle_button.setObjectName("PerspectiveToggleBtn")
toggle_button.setText(delegate.icons["angle-left"])
toggle_button.setMinimumHeight(50)
toggle_button.setFixedWidth(40)
indicator = QtWidgets.QLabel("", parent=header_widget)
indicator.setFixedWidth(30)
indicator.setAlignment(QtCore.Qt.AlignCenter)
indicator.setObjectName("PerspectiveIndicator")
name = EllidableLabel('*Name of inspected', parent=header_widget)
header_layout = QtWidgets.QHBoxLayout(header_widget)
header_layout.setAlignment(QtCore.Qt.AlignLeft)
header_layout.addWidget(toggle_button)
header_layout.addWidget(indicator)
header_layout.addWidget(name)
header_layout.setContentsMargins(0, 0, 0, 0)
header_layout.setSpacing(10)
header_widget.setLayout(header_layout)
main_layout.setAlignment(QtCore.Qt.AlignTop)
main_layout.addWidget(header_widget)
scroll_widget = QtWidgets.QScrollArea(self)
scroll_widget.setObjectName("PerspectiveScrollContent")
contents_widget = QtWidgets.QWidget(scroll_widget)
contents_widget.setObjectName("PerspectiveWidgetContent")
layout = QtWidgets.QVBoxLayout()
layout.setAlignment(QtCore.Qt.AlignTop)
layout.setContentsMargins(0, 0, 0, 0)
documentation = ExpandableWidget(self, self.l_doc)
doc_label = PerspectiveLabel()
documentation.set_content(doc_label)
layout.addWidget(documentation)
path = ExpandableWidget(self, self.l_path)
path_label = PerspectiveLabel()
path.set_content(path_label)
layout.addWidget(path)
records = ExpandableWidget(self, self.l_rec)
layout.addWidget(records)
contents_widget.setLayout(layout)
terminal_view = view.TerminalView()
terminal_view.setObjectName("TerminalView")
terminal_model = model.TerminalModel()
terminal_proxy = model.TerminalProxy(terminal_view)
terminal_proxy.setSourceModel(terminal_model)
terminal_view.setModel(terminal_proxy)
terminal_delegate = delegate.TerminalItem()
terminal_view.setItemDelegate(terminal_delegate)
records.set_content(terminal_view)
scroll_widget.setWidgetResizable(True)
scroll_widget.setWidget(contents_widget)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(0)
main_layout.addWidget(scroll_widget)
self.setLayout(main_layout)
self.terminal_view = terminal_view
self.terminal_model = terminal_model
self.terminal_proxy = terminal_proxy
self.indicator = indicator
self.scroll_widget = scroll_widget
self.contents_widget = contents_widget
self.toggle_button = toggle_button
self.name_widget = name
self.documentation = documentation
self.path = path
self.records = records
self.toggle_button.clicked.connect(self.toggle_me)
self.last_type = None
self.last_item_id = None
self.last_id = None
def trim(self, docstring):
if not docstring:
return ""
# Convert tabs to spaces (following the normal Python rules)
# and split into a list of lines:
lines = docstring.expandtabs().splitlines()
# Determine minimum indentation (first line doesn't count):
try:
indent = sys.maxint
max = sys.maxint
except Exception:
indent = sys.maxsize
max = sys.maxsize
for line in lines[1:]:
stripped = line.lstrip()
if stripped:
indent = min(indent, len(line) - len(stripped))
# Remove indentation (first line is special):
trimmed = [lines[0].strip()]
if indent < max:
for line in lines[1:]:
trimmed.append(line[indent:].rstrip())
# Strip off trailing and leading blank lines:
while trimmed and not trimmed[-1]:
trimmed.pop()
while trimmed and not trimmed[0]:
trimmed.pop(0)
# Return a single string:
return "\n".join(trimmed)
def set_indicator_state(self, state):
self.indicator.setProperty("state", state)
self.indicator.style().polish(self.indicator)
def reset(self):
self.last_id = None
self.set_records(list())
self.set_indicator_state(None)
def update_context(self, plugin_item, instance_item):
if not self.last_item_id or not self.last_type:
return
if self.last_type == model.PluginType:
if not self.last_id:
_item_id = plugin_item.data(Roles.ObjectUIdRole)
if _item_id != self.last_item_id:
return
self.last_id = plugin_item.plugin.id
elif self.last_id != plugin_item.plugin.id:
return
self.set_context(plugin_item.index())
return
if self.last_type == model.InstanceType:
if not self.last_id:
_item_id = instance_item.data(Roles.ObjectUIdRole)
if _item_id != self.last_item_id:
return
self.last_id = instance_item.instance.id
elif self.last_id != instance_item.instance.id:
return
self.set_context(instance_item.index())
return
def set_context(self, index):
if not index or not index.isValid():
index_type = None
else:
index_type = index.data(Roles.TypeRole)
if index_type == model.InstanceType:
item_id = index.data(Roles.ObjectIdRole)
publish_states = index.data(Roles.PublishFlagsRole)
if publish_states & InstanceStates.ContextType:
type_indicator = "C"
else:
type_indicator = "I"
if publish_states & InstanceStates.InProgress:
self.set_indicator_state("active")
elif publish_states & InstanceStates.HasError:
self.set_indicator_state("error")
elif publish_states & InstanceStates.HasWarning:
self.set_indicator_state("warning")
elif publish_states & InstanceStates.HasFinished:
self.set_indicator_state("ok")
else:
self.set_indicator_state(None)
self.documentation.setVisible(False)
self.path.setVisible(False)
elif index_type == model.PluginType:
item_id = index.data(Roles.ObjectIdRole)
type_indicator = "P"
doc = index.data(Roles.DocstringRole)
doc_str = ""
if doc:
doc_str = self.trim(doc)
publish_states = index.data(Roles.PublishFlagsRole)
if publish_states & PluginStates.InProgress:
self.set_indicator_state("active")
elif publish_states & PluginStates.HasError:
self.set_indicator_state("error")
elif publish_states & PluginStates.HasWarning:
self.set_indicator_state("warning")
elif publish_states & PluginStates.WasProcessed:
self.set_indicator_state("ok")
else:
self.set_indicator_state(None)
self.documentation.toggle_content(bool(doc_str))
self.documentation.content.setText(doc_str)
path = index.data(Roles.PathModuleRole) or ""
self.path.toggle_content(path.strip() != "")
self.path.content.setText(path)
self.documentation.setVisible(True)
self.path.setVisible(True)
else:
self.last_type = None
self.last_id = None
self.indicator.setText("?")
self.set_indicator_state(None)
self.documentation.setVisible(False)
self.path.setVisible(False)
self.records.setVisible(False)
return
self.last_type = index_type
self.last_id = item_id
self.last_item_id = index.data(Roles.ObjectUIdRole)
self.indicator.setText(type_indicator)
label = index.data(QtCore.Qt.DisplayRole)
self.name_widget.setText(label)
self.records.setVisible(True)
records = index.data(Roles.LogRecordsRole) or []
self.set_records(records)
def set_records(self, records):
len_records = 0
if records:
len_records += len(records)
data = {"records": records}
self.terminal_model.reset()
self.terminal_model.update_with_result(data)
self.records.button_toggle_text.setText(
"{} ({})".format(self.l_rec, len_records)
)
self.records.toggle_content(len_records > 0)
def toggle_me(self):
self.parent_widget.parent().toggle_perspective_widget()
class ClickableWidget(QtWidgets.QLabel):
clicked = QtCore.Signal()
def mouseReleaseEvent(self, event):
if event.button() == QtCore.Qt.LeftButton:
self.clicked.emit()
super(ClickableWidget, self).mouseReleaseEvent(event)
class ExpandableWidget(QtWidgets.QWidget):
content = None
def __init__(self, parent, title):
super(ExpandableWidget, self).__init__(parent)
top_part = ClickableWidget(parent=self)
top_part.setObjectName("ExpandableHeader")
button_size = QtCore.QSize(5, 5)
button_toggle = QtWidgets.QToolButton(parent=top_part)
button_toggle.setIconSize(button_size)
button_toggle.setArrowType(QtCore.Qt.RightArrow)
button_toggle.setCheckable(True)
button_toggle.setChecked(False)
button_toggle_text = QtWidgets.QLabel(title, parent=top_part)
layout = QtWidgets.QHBoxLayout(top_part)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(5)
layout.addWidget(button_toggle)
layout.addWidget(button_toggle_text)
top_part.setLayout(layout)
main_layout = QtWidgets.QVBoxLayout(self)
main_layout.setContentsMargins(9, 9, 9, 0)
content = QtWidgets.QFrame(self)
content.setObjectName("ExpandableWidgetContent")
content.setVisible(False)
content_layout = QtWidgets.QVBoxLayout(content)
main_layout.addWidget(top_part)
main_layout.addWidget(content)
self.setLayout(main_layout)
self.setAttribute(QtCore.Qt.WA_StyledBackground)
self.top_part = top_part
self.button_toggle = button_toggle
self.button_toggle_text = button_toggle_text
self.content_widget = content
self.content_layout = content_layout
self.top_part.clicked.connect(self.top_part_clicked)
self.button_toggle.clicked.connect(self.toggle_content)
def top_part_clicked(self):
self.toggle_content(not self.button_toggle.isChecked())
def toggle_content(self, *args):
if len(args) > 0:
checked = args[0]
else:
checked = self.button_toggle.isChecked()
arrow_type = QtCore.Qt.RightArrow
if checked:
arrow_type = QtCore.Qt.DownArrow
self.button_toggle.setChecked(checked)
self.button_toggle.setArrowType(arrow_type)
self.content_widget.setVisible(checked)
def resizeEvent(self, event):
super(ExpandableWidget, self).resizeEvent(event)
self.content.updateGeometry()
def set_content(self, in_widget):
if self.content:
self.content.hide()
self.content_layout.removeWidget(self.content)
self.content_layout.addWidget(in_widget)
self.content = in_widget
class ButtonWithMenu(QtWidgets.QWidget):
def __init__(self, button_title, parent=None):
super(ButtonWithMenu, self).__init__(parent=parent)
self.setSizePolicy(QtWidgets.QSizePolicy(
QtWidgets.QSizePolicy.Maximum, QtWidgets.QSizePolicy.Maximum
))
self.layout = QtWidgets.QHBoxLayout(self)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.setSpacing(0)
self.menu = QtWidgets.QMenu()
# TODO move to stylesheets
self.menu.setStyleSheet("""
*{color: #fff; background-color: #555; border: 1px solid #222;}
::item {background-color: transparent;padding: 5px;
padding-left: 10px;padding-right: 10px;}
::item:selected {background-color: #666;}
""")
self.button = QtWidgets.QPushButton(button_title)
self.button.setObjectName("ButtonWithMenu")
self.layout.addWidget(self.button)
self.button.clicked.connect(self.btn_clicked)
def btn_clicked(self):
self.menu.popup(self.button.mapToGlobal(
QtCore.QPoint(0, self.button.height())
))
def addItem(self, text, callback):
self.menu.addAction(text, callback)
self.button.setToolTip("Select to apply predefined presets")
def clearMenu(self):
self.menu.clear()
self.button.setToolTip("Presets not found")
class CommentBox(QtWidgets.QLineEdit):
def __init__(self, placeholder_text, parent=None):
super(CommentBox, self).__init__(parent=parent)
self.placeholder = QtWidgets.QLabel(placeholder_text, self)
self.placeholder.move(2, 2)
def focusInEvent(self, event):
self.placeholder.setVisible(False)
return super(CommentBox, self).focusInEvent(event)
def focusOutEvent(self, event):
current_text = self.text()
current_text = current_text.strip(" ")
self.setText(current_text)
if not self.text():
self.placeholder.setVisible(True)
return super(CommentBox, self).focusOutEvent(event)
class TerminalDetail(QtWidgets.QTextEdit):
def __init__(self, text, *args, **kwargs):
super(TerminalDetail, self).__init__(*args, **kwargs)
self.setReadOnly(True)
self.setHtml(text)
self.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction)
self.setWordWrapMode(
QtGui.QTextOption.WrapAtWordBoundaryOrAnywhere
)
def sizeHint(self):
content_margins = (
self.contentsMargins().top()
+ self.contentsMargins().bottom()
)
size = self.document().documentLayout().documentSize().toSize()
size.setHeight(size.height() + content_margins)
return size
class FilterButton(QtWidgets.QPushButton):
def __init__(self, name, *args, **kwargs):
self.filter_name = name
super(FilterButton, self).__init__(*args, **kwargs)
self.toggled.connect(self.on_toggle)
self.setProperty("type", name)
self.setObjectName("TerminalFilerBtn")
self.setCheckable(True)
self.setChecked(
model.TerminalProxy.filter_buttons_checks[name]
)
def on_toggle(self, toggle_state):
model.TerminalProxy.change_filter(self.filter_name, toggle_state)
class TerminalFilterWidget(QtWidgets.QWidget):
# timer.timeout.connect(lambda: self._update(self.parent_widget))
def __init__(self, *args, **kwargs):
super(TerminalFilterWidget, self).__init__(*args, **kwargs)
self.setObjectName("TerminalFilterWidget")
self.filter_changed = QtCore.Signal()
info_icon = awesome.tags["info"]
log_icon = awesome.tags["circle"]
error_icon = awesome.tags["exclamation-triangle"]
filter_buttons = (
FilterButton("info", info_icon, self),
FilterButton("log_debug", log_icon, self),
FilterButton("log_info", log_icon, self),
FilterButton("log_warning", log_icon, self),
FilterButton("log_error", log_icon, self),
FilterButton("log_critical", log_icon, self),
FilterButton("error", error_icon, self)
)
layout = QtWidgets.QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
# Add spacers
spacer = QtWidgets.QWidget()
spacer.setAttribute(QtCore.Qt.WA_TranslucentBackground)
layout.addWidget(spacer, 1)
for btn in filter_buttons:
layout.addWidget(btn)
self.setLayout(layout)
self.filter_buttons = filter_buttons

Some files were not shown because too many files have changed in this diff Show more