diff --git a/openpype/hosts/fusion/api/menu.py b/openpype/hosts/fusion/api/menu.py
index 3f04bf839b9..9093aa9e5e0 100644
--- a/openpype/hosts/fusion/api/menu.py
+++ b/openpype/hosts/fusion/api/menu.py
@@ -10,8 +10,10 @@
from avalon.tools import (
creator,
- loader,
sceneinventory,
+)
+from openpype.tools import (
+ loader,
libraryloader
)
diff --git a/openpype/hosts/fusion/api/pipeline.py b/openpype/hosts/fusion/api/pipeline.py
index 00955300876..688e75f6fe6 100644
--- a/openpype/hosts/fusion/api/pipeline.py
+++ b/openpype/hosts/fusion/api/pipeline.py
@@ -3,7 +3,7 @@
"""
import os
-from avalon.tools import workfiles
+from openpype.tools import workfiles
from avalon import api as avalon
from pyblish import api as pyblish
from openpype.api import Logger
diff --git a/openpype/hosts/hiero/api/menu.py b/openpype/hosts/hiero/api/menu.py
index ab492510935..bcd78aa5bb8 100644
--- a/openpype/hosts/hiero/api/menu.py
+++ b/openpype/hosts/hiero/api/menu.py
@@ -41,7 +41,8 @@ def menu_install():
apply_colorspace_project, apply_colorspace_clips
)
# here is the best place to add menu
- from avalon.tools import cbloader, creator, sceneinventory
+ from avalon.tools import creator, sceneinventory
+ from openpype.tools import loader
from avalon.vendor.Qt import QtGui
menu_name = os.environ['AVALON_LABEL']
@@ -90,7 +91,7 @@ def menu_install():
loader_action = menu.addAction("Load ...")
loader_action.setIcon(QtGui.QIcon("icons:CopyRectangle.png"))
- loader_action.triggered.connect(cbloader.show)
+ loader_action.triggered.connect(loader.show)
sceneinventory_action = menu.addAction("Manage ...")
sceneinventory_action.setIcon(QtGui.QIcon("icons:CopyRectangle.png"))
diff --git a/openpype/hosts/hiero/api/pipeline.py b/openpype/hosts/hiero/api/pipeline.py
index ab7e2bdabf0..12f6923de77 100644
--- a/openpype/hosts/hiero/api/pipeline.py
+++ b/openpype/hosts/hiero/api/pipeline.py
@@ -4,10 +4,8 @@
import os
import contextlib
from collections import OrderedDict
-from avalon.tools import (
- workfiles,
- publish as _publish
-)
+from avalon.tools import publish as _publish
+from openpype.tools import workfiles
from avalon.pipeline import AVALON_CONTAINER_ID
from avalon import api as avalon
from avalon import schema
diff --git a/openpype/hosts/houdini/startup/MainMenuCommon.xml b/openpype/hosts/houdini/startup/MainMenuCommon.xml
index 77ee182e7c2..76585085e24 100644
--- a/openpype/hosts/houdini/startup/MainMenuCommon.xml
+++ b/openpype/hosts/houdini/startup/MainMenuCommon.xml
@@ -15,8 +15,8 @@ creator.show()
diff --git a/openpype/hosts/maya/api/__init__.py b/openpype/hosts/maya/api/__init__.py
index 9219da407f8..db4dbf29c5f 100644
--- a/openpype/hosts/maya/api/__init__.py
+++ b/openpype/hosts/maya/api/__init__.py
@@ -8,7 +8,7 @@
from avalon import pipeline
from avalon.maya import suspended_refresh
from avalon.maya.pipeline import IS_HEADLESS
-from avalon.tools import workfiles
+from openpype.tools import workfiles
from pyblish import api as pyblish
from openpype.lib import any_outdated
import openpype.hosts.maya
diff --git a/openpype/hosts/maya/api/customize.py b/openpype/hosts/maya/api/customize.py
index 22945471b72..a84412963b3 100644
--- a/openpype/hosts/maya/api/customize.py
+++ b/openpype/hosts/maya/api/customize.py
@@ -79,7 +79,7 @@ def override_toolbox_ui():
log.warning("Could not import SceneInventory tool")
try:
- import avalon.tools.loader as loader
+ import openpype.tools.loader as loader
except Exception:
log.warning("Could not import Loader tool")
diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py
index 257bf8d64ea..34cf34392e7 100644
--- a/openpype/hosts/nuke/api/lib.py
+++ b/openpype/hosts/nuke/api/lib.py
@@ -7,7 +7,7 @@
from avalon import api, io, lib
-from avalon.tools import workfiles
+from openpype.tools import workfiles
import avalon.nuke
from avalon.nuke import lib as anlib
from avalon.nuke import (
diff --git a/openpype/hosts/resolve/api/menu.py b/openpype/hosts/resolve/api/menu.py
index e7be3fc963f..c639fd2db81 100644
--- a/openpype/hosts/resolve/api/menu.py
+++ b/openpype/hosts/resolve/api/menu.py
@@ -10,11 +10,13 @@
from avalon.tools import (
creator,
- loader,
sceneinventory,
- libraryloader,
subsetmanager
)
+from openpype.tools import (
+ loader,
+ libraryloader,
+)
def load_stylesheet():
diff --git a/openpype/hosts/resolve/api/pipeline.py b/openpype/hosts/resolve/api/pipeline.py
index a659ac7e51c..80249310e8d 100644
--- a/openpype/hosts/resolve/api/pipeline.py
+++ b/openpype/hosts/resolve/api/pipeline.py
@@ -4,7 +4,7 @@
import os
import contextlib
from collections import OrderedDict
-from avalon.tools import workfiles
+from openpype.tools import workfiles
from avalon import api as avalon
from avalon import schema
from avalon.pipeline import AVALON_CONTAINER_ID
diff --git a/openpype/modules/default_modules/avalon_apps/avalon_app.py b/openpype/modules/default_modules/avalon_apps/avalon_app.py
index b3b7dd84847..4459fa2cac4 100644
--- a/openpype/modules/default_modules/avalon_apps/avalon_app.py
+++ b/openpype/modules/default_modules/avalon_apps/avalon_app.py
@@ -55,11 +55,11 @@ def get_global_environments(self):
def tray_init(self):
# Add library tool
try:
- from avalon.tools.libraryloader import app
- from avalon import style
from Qt import QtGui
+ from avalon import style
+ from openpype.tools.libraryloader import LibraryLoaderWindow
- self.libraryloader = app.Window(
+ self.libraryloader = LibraryLoaderWindow(
icon=QtGui.QIcon(resources.get_openpype_icon_filepath()),
show_projects=True,
show_libraries=True
diff --git a/openpype/modules/default_modules/sync_server/tray/models.py b/openpype/modules/default_modules/sync_server/tray/models.py
index 8c86d3b98f6..c2c63c68eab 100644
--- a/openpype/modules/default_modules/sync_server/tray/models.py
+++ b/openpype/modules/default_modules/sync_server/tray/models.py
@@ -5,7 +5,7 @@
from Qt import QtCore
from Qt.QtCore import Qt
-from avalon.tools.delegates import pretty_timestamp
+from openpype.tools.utils.delegates import pretty_timestamp
from avalon.vendor import qtawesome
from openpype.lib import PypeLogger
diff --git a/openpype/modules/default_modules/sync_server/tray/widgets.py b/openpype/modules/default_modules/sync_server/tray/widgets.py
index c9160733a01..c9b58ebe7c9 100644
--- a/openpype/modules/default_modules/sync_server/tray/widgets.py
+++ b/openpype/modules/default_modules/sync_server/tray/widgets.py
@@ -14,7 +14,7 @@
from openpype.api import get_local_site_id
from openpype.lib import PypeLogger
-from avalon.tools.delegates import pretty_timestamp
+from openpype.tools.utils.delegates import pretty_timestamp
from avalon.vendor import qtawesome
from .models import (
diff --git a/openpype/tools/libraryloader/__init__.py b/openpype/tools/libraryloader/__init__.py
new file mode 100644
index 00000000000..bbf4a1087d6
--- /dev/null
+++ b/openpype/tools/libraryloader/__init__.py
@@ -0,0 +1,11 @@
+from .app import (
+ LibraryLoaderWindow,
+ show,
+ cli
+)
+
+__all__ = [
+ "LibraryLoaderWindow",
+ "show",
+ "cli",
+]
diff --git a/openpype/tools/libraryloader/__main__.py b/openpype/tools/libraryloader/__main__.py
new file mode 100644
index 00000000000..d77bc585c5c
--- /dev/null
+++ b/openpype/tools/libraryloader/__main__.py
@@ -0,0 +1,5 @@
+from . import cli
+
+if __name__ == '__main__':
+ import sys
+ sys.exit(cli(sys.argv[1:]))
diff --git a/openpype/tools/libraryloader/app.py b/openpype/tools/libraryloader/app.py
new file mode 100644
index 00000000000..362d05cce64
--- /dev/null
+++ b/openpype/tools/libraryloader/app.py
@@ -0,0 +1,591 @@
+import sys
+import time
+
+from Qt import QtWidgets, QtCore, QtGui
+
+from avalon import style
+from avalon.api import AvalonMongoDB
+from openpype.tools.utils import lib as tools_lib
+from openpype.tools.loader.widgets import (
+ ThumbnailWidget,
+ VersionWidget,
+ FamilyListWidget,
+ RepresentationWidget
+)
+from openpype.tools.utils.widgets import AssetWidget
+
+from openpype.modules import ModulesManager
+
+from . import lib
+from .widgets import LibrarySubsetWidget
+
+module = sys.modules[__name__]
+module.window = None
+
+
+class LibraryLoaderWindow(QtWidgets.QDialog):
+ """Asset library loader interface"""
+
+ tool_title = "Library Loader 0.5"
+ tool_name = "library_loader"
+
+ def __init__(
+ self, parent=None, icon=None, show_projects=False, show_libraries=True
+ ):
+ super(LibraryLoaderWindow, self).__init__(parent)
+
+ self._initial_refresh = False
+ self._ignore_project_change = False
+
+ # Enable minimize and maximize for app
+ self.setWindowTitle(self.tool_title)
+ self.setWindowFlags(QtCore.Qt.Window)
+ self.setFocusPolicy(QtCore.Qt.StrongFocus)
+ if icon is not None:
+ self.setWindowIcon(icon)
+ # self.setAttribute(QtCore.Qt.WA_DeleteOnClose)
+
+ body = QtWidgets.QWidget()
+ footer = QtWidgets.QWidget()
+ footer.setFixedHeight(20)
+
+ container = QtWidgets.QWidget()
+
+ self.dbcon = AvalonMongoDB()
+ self.dbcon.install()
+ self.dbcon.Session["AVALON_PROJECT"] = None
+
+ self.show_projects = show_projects
+ self.show_libraries = show_libraries
+
+ # Groups config
+ self.groups_config = tools_lib.GroupsConfig(self.dbcon)
+ self.family_config_cache = tools_lib.FamilyConfigCache(self.dbcon)
+
+ assets = AssetWidget(
+ self.dbcon, multiselection=True, parent=self
+ )
+ families = FamilyListWidget(
+ self.dbcon, self.family_config_cache, parent=self
+ )
+ subsets = LibrarySubsetWidget(
+ self.dbcon,
+ self.groups_config,
+ self.family_config_cache,
+ tool_name=self.tool_name,
+ parent=self
+ )
+
+ version = VersionWidget(self.dbcon)
+ thumbnail = ThumbnailWidget(self.dbcon)
+
+ # Project
+ self.combo_projects = QtWidgets.QComboBox()
+
+ # Create splitter to show / hide family filters
+ asset_filter_splitter = QtWidgets.QSplitter()
+ asset_filter_splitter.setOrientation(QtCore.Qt.Vertical)
+ asset_filter_splitter.addWidget(self.combo_projects)
+ asset_filter_splitter.addWidget(assets)
+ asset_filter_splitter.addWidget(families)
+ asset_filter_splitter.setStretchFactor(1, 65)
+ asset_filter_splitter.setStretchFactor(2, 35)
+
+ manager = ModulesManager()
+ sync_server = manager.modules_by_name["sync_server"]
+
+ representations = RepresentationWidget(self.dbcon)
+ thumb_ver_splitter = QtWidgets.QSplitter()
+ thumb_ver_splitter.setOrientation(QtCore.Qt.Vertical)
+ thumb_ver_splitter.addWidget(thumbnail)
+ thumb_ver_splitter.addWidget(version)
+ if sync_server.enabled:
+ thumb_ver_splitter.addWidget(representations)
+ thumb_ver_splitter.setStretchFactor(0, 30)
+ thumb_ver_splitter.setStretchFactor(1, 35)
+
+ container_layout = QtWidgets.QHBoxLayout(container)
+ container_layout.setContentsMargins(0, 0, 0, 0)
+ split = QtWidgets.QSplitter()
+ split.addWidget(asset_filter_splitter)
+ split.addWidget(subsets)
+ split.addWidget(thumb_ver_splitter)
+ split.setSizes([180, 950, 200])
+ container_layout.addWidget(split)
+
+ body_layout = QtWidgets.QHBoxLayout(body)
+ body_layout.addWidget(container)
+ body_layout.setContentsMargins(0, 0, 0, 0)
+
+ message = QtWidgets.QLabel()
+ message.hide()
+
+ footer_layout = QtWidgets.QVBoxLayout(footer)
+ footer_layout.addWidget(message)
+ footer_layout.setContentsMargins(0, 0, 0, 0)
+
+ layout = QtWidgets.QVBoxLayout(self)
+ layout.addWidget(body)
+ layout.addWidget(footer)
+
+ self.data = {
+ "widgets": {
+ "families": families,
+ "assets": assets,
+ "subsets": subsets,
+ "version": version,
+ "thumbnail": thumbnail,
+ "representations": representations
+ },
+ "label": {
+ "message": message,
+ },
+ "state": {
+ "assetIds": None
+ }
+ }
+
+ families.active_changed.connect(subsets.set_family_filters)
+ assets.selection_changed.connect(self.on_assetschanged)
+ assets.refresh_triggered.connect(self.on_assetschanged)
+ assets.view.clicked.connect(self.on_assetview_click)
+ subsets.active_changed.connect(self.on_subsetschanged)
+ subsets.version_changed.connect(self.on_versionschanged)
+ self.combo_projects.currentTextChanged.connect(self.on_project_change)
+
+ self.sync_server = sync_server
+
+ # Set default thumbnail on start
+ thumbnail.set_thumbnail(None)
+
+ # Defaults
+ if sync_server.enabled:
+ split.setSizes([250, 1000, 550])
+ self.resize(1800, 900)
+ else:
+ split.setSizes([250, 850, 200])
+ self.resize(1300, 700)
+
+ def showEvent(self, event):
+ super(LibraryLoaderWindow, self).showEvent(event)
+ if not self._initial_refresh:
+ self.refresh()
+
+ def on_assetview_click(self, *args):
+ subsets_widget = self.data["widgets"]["subsets"]
+ selection_model = subsets_widget.view.selectionModel()
+ if selection_model.selectedIndexes():
+ selection_model.clearSelection()
+
+ def _set_projects(self):
+ # Store current project
+ old_project_name = self.current_project
+
+ self._ignore_project_change = True
+
+ # Cleanup
+ self.combo_projects.clear()
+
+ # Fill combobox with projects
+ select_project_item = QtGui.QStandardItem("< Select project >")
+ select_project_item.setData(None, QtCore.Qt.UserRole + 1)
+
+ combobox_items = [select_project_item]
+
+ project_names = self.get_filtered_projects()
+
+ for project_name in sorted(project_names):
+ item = QtGui.QStandardItem(project_name)
+ item.setData(project_name, QtCore.Qt.UserRole + 1)
+ combobox_items.append(item)
+
+ root_item = self.combo_projects.model().invisibleRootItem()
+ root_item.appendRows(combobox_items)
+
+ index = 0
+ self._ignore_project_change = False
+
+ if old_project_name:
+ index = self.combo_projects.findText(
+ old_project_name, QtCore.Qt.MatchFixedString
+ )
+
+ self.combo_projects.setCurrentIndex(index)
+
+ def get_filtered_projects(self):
+ projects = list()
+ for project in self.dbcon.projects():
+ is_library = project.get("data", {}).get("library_project", False)
+ if (
+ (is_library and self.show_libraries) or
+ (not is_library and self.show_projects)
+ ):
+ projects.append(project["name"])
+
+ return projects
+
+ def on_project_change(self):
+ if self._ignore_project_change:
+ return
+
+ row = self.combo_projects.currentIndex()
+ index = self.combo_projects.model().index(row, 0)
+ project_name = index.data(QtCore.Qt.UserRole + 1)
+
+ self.dbcon.Session["AVALON_PROJECT"] = project_name
+
+ _config = lib.find_config()
+ if hasattr(_config, "install"):
+ _config.install()
+ else:
+ print(
+ "Config `%s` has no function `install`" % _config.__name__
+ )
+
+ self.family_config_cache.refresh()
+ self.groups_config.refresh()
+
+ self._refresh_assets()
+ self._assetschanged()
+
+ project_name = self.dbcon.active_project() or "No project selected"
+ title = "{} - {}".format(self.tool_title, project_name)
+ self.setWindowTitle(title)
+
+ subsets = self.data["widgets"]["subsets"]
+ subsets.on_project_change(self.dbcon.Session["AVALON_PROJECT"])
+
+ representations = self.data["widgets"]["representations"]
+ representations.on_project_change(self.dbcon.Session["AVALON_PROJECT"])
+
+ @property
+ def current_project(self):
+ if (
+ not self.dbcon.active_project() or
+ self.dbcon.active_project() == ""
+ ):
+ return None
+
+ return self.dbcon.active_project()
+
+ # -------------------------------
+ # Delay calling blocking methods
+ # -------------------------------
+
+ def refresh(self):
+ self.echo("Fetching results..")
+ tools_lib.schedule(self._refresh, 50, channel="mongo")
+
+ def on_assetschanged(self, *args):
+ self.echo("Fetching asset..")
+ tools_lib.schedule(self._assetschanged, 50, channel="mongo")
+
+ def on_subsetschanged(self, *args):
+ self.echo("Fetching subset..")
+ tools_lib.schedule(self._subsetschanged, 50, channel="mongo")
+
+ def on_versionschanged(self, *args):
+ self.echo("Fetching version..")
+ tools_lib.schedule(self._versionschanged, 150, channel="mongo")
+
+ def set_context(self, context, refresh=True):
+ self.echo("Setting context: {}".format(context))
+ lib.schedule(
+ lambda: self._set_context(context, refresh=refresh),
+ 50, channel="mongo"
+ )
+
+ # ------------------------------
+ def _refresh(self):
+ if not self._initial_refresh:
+ self._initial_refresh = True
+ self._set_projects()
+
+ def _refresh_assets(self):
+ """Load assets from database"""
+ if self.current_project is not None:
+ # Ensure a project is loaded
+ project_doc = self.dbcon.find_one(
+ {"type": "project"},
+ {"type": 1}
+ )
+ assert project_doc, "This is a bug"
+
+ assets_widget = self.data["widgets"]["assets"]
+ assets_widget.model.stop_fetch_thread()
+ assets_widget.refresh()
+ assets_widget.setFocus()
+
+ families = self.data["widgets"]["families"]
+ families.refresh()
+
+ def clear_assets_underlines(self):
+ last_asset_ids = self.data["state"]["assetIds"]
+ if not last_asset_ids:
+ return
+
+ assets_widget = self.data["widgets"]["assets"]
+ id_role = assets_widget.model.ObjectIdRole
+
+ for index in tools_lib.iter_model_rows(assets_widget.model, 0):
+ if index.data(id_role) not in last_asset_ids:
+ continue
+
+ assets_widget.model.setData(
+ index, [], assets_widget.model.subsetColorsRole
+ )
+
+ def _assetschanged(self):
+ """Selected assets have changed"""
+ t1 = time.time()
+
+ assets_widget = self.data["widgets"]["assets"]
+ subsets_widget = self.data["widgets"]["subsets"]
+ subsets_model = subsets_widget.model
+
+ subsets_model.clear()
+ self.clear_assets_underlines()
+
+ if not self.dbcon.Session.get("AVALON_PROJECT"):
+ subsets_widget.set_loading_state(
+ loading=False,
+ empty=True
+ )
+ return
+
+ # filter None docs they are silo
+ asset_docs = assets_widget.get_selected_assets()
+ if len(asset_docs) == 0:
+ return
+
+ asset_ids = [asset_doc["_id"] for asset_doc in asset_docs]
+ # Start loading
+ subsets_widget.set_loading_state(
+ loading=bool(asset_ids),
+ empty=True
+ )
+
+ def on_refreshed(has_item):
+ empty = not has_item
+ subsets_widget.set_loading_state(loading=False, empty=empty)
+ subsets_model.refreshed.disconnect()
+ self.echo("Duration: %.3fs" % (time.time() - t1))
+
+ subsets_model.refreshed.connect(on_refreshed)
+
+ subsets_model.set_assets(asset_ids)
+ subsets_widget.view.setColumnHidden(
+ subsets_model.Columns.index("asset"),
+ len(asset_ids) < 2
+ )
+
+ # Clear the version information on asset change
+ self.data["widgets"]["version"].set_version(None)
+ self.data["widgets"]["thumbnail"].set_thumbnail(asset_docs)
+
+ self.data["state"]["assetIds"] = asset_ids
+
+ representations = self.data["widgets"]["representations"]
+ representations.set_version_ids([]) # reset repre list
+
+ self.echo("Duration: %.3fs" % (time.time() - t1))
+
+ def _subsetschanged(self):
+ asset_ids = self.data["state"]["assetIds"]
+ # Skip setting colors if not asset multiselection
+ if not asset_ids or len(asset_ids) < 2:
+ self._versionschanged()
+ return
+
+ subsets = self.data["widgets"]["subsets"]
+ selected_subsets = subsets.selected_subsets(_merged=True, _other=False)
+
+ asset_models = {}
+ asset_ids = []
+ for subset_node in selected_subsets:
+ asset_ids.extend(subset_node.get("assetIds", []))
+ asset_ids = set(asset_ids)
+
+ for subset_node in selected_subsets:
+ for asset_id in asset_ids:
+ if asset_id not in asset_models:
+ asset_models[asset_id] = []
+
+ color = None
+ if asset_id in subset_node.get("assetIds", []):
+ color = subset_node["subsetColor"]
+
+ asset_models[asset_id].append(color)
+
+ self.clear_assets_underlines()
+
+ assets_widget = self.data["widgets"]["assets"]
+ indexes = assets_widget.view.selectionModel().selectedRows()
+
+ for index in indexes:
+ id = index.data(assets_widget.model.ObjectIdRole)
+ if id not in asset_models:
+ continue
+
+ assets_widget.model.setData(
+ index, asset_models[id], assets_widget.model.subsetColorsRole
+ )
+ # Trigger repaint
+ assets_widget.view.updateGeometries()
+ # Set version in Version Widget
+ self._versionschanged()
+
+ def _versionschanged(self):
+
+ subsets = self.data["widgets"]["subsets"]
+ selection = subsets.view.selectionModel()
+
+ # Active must be in the selected rows otherwise we
+ # assume it's not actually an "active" current index.
+ version_docs = None
+ version_doc = None
+ active = selection.currentIndex()
+ rows = selection.selectedRows(column=active.column())
+ if active and active in rows:
+ item = active.data(subsets.model.ItemRole)
+ if (
+ item is not None
+ and not (item.get("isGroup") or item.get("isMerged"))
+ ):
+ version_doc = item["version_document"]
+
+ if rows:
+ version_docs = []
+ for index in rows:
+ if not index or not index.isValid():
+ continue
+ item = index.data(subsets.model.ItemRole)
+ if (
+ item is None
+ or item.get("isGroup")
+ or item.get("isMerged")
+ ):
+ continue
+ version_docs.append(item["version_document"])
+
+ self.data["widgets"]["version"].set_version(version_doc)
+
+ thumbnail_docs = version_docs
+ if not thumbnail_docs:
+ assets_widget = self.data["widgets"]["assets"]
+ asset_docs = assets_widget.get_selected_assets()
+ if len(asset_docs) > 0:
+ thumbnail_docs = asset_docs
+
+ self.data["widgets"]["thumbnail"].set_thumbnail(thumbnail_docs)
+
+ representations = self.data["widgets"]["representations"]
+ version_ids = [doc["_id"] for doc in version_docs or []]
+ representations.set_version_ids(version_ids)
+
+ def _set_context(self, context, refresh=True):
+ """Set the selection in the interface using a context.
+ The context must contain `asset` data by name.
+ Note: Prior to setting context ensure `refresh` is triggered so that
+ the "silos" are listed correctly, aside from that setting the
+ context will force a refresh further down because it changes
+ the active silo and asset.
+ Args:
+ context (dict): The context to apply.
+ Returns:
+ None
+ """
+
+ asset = context.get("asset", None)
+ if asset is None:
+ return
+
+ if refresh:
+ # Workaround:
+ # Force a direct (non-scheduled) refresh prior to setting the
+ # asset widget's silo and asset selection to ensure it's correctly
+ # displaying the silo tabs. Calling `window.refresh()` and directly
+ # `window.set_context()` the `set_context()` seems to override the
+ # scheduled refresh and the silo tabs are not shown.
+ self._refresh_assets()
+
+ asset_widget = self.data["widgets"]["assets"]
+ asset_widget.select_assets(asset)
+
+ def echo(self, message):
+ widget = self.data["label"]["message"]
+ widget.setText(str(message))
+ widget.show()
+ print(message)
+
+ tools_lib.schedule(widget.hide, 5000, channel="message")
+
+ def closeEvent(self, event):
+ # Kill on holding SHIFT
+ modifiers = QtWidgets.QApplication.queryKeyboardModifiers()
+ shift_pressed = QtCore.Qt.ShiftModifier & modifiers
+
+ if shift_pressed:
+ print("Force quitted..")
+ self.setAttribute(QtCore.Qt.WA_DeleteOnClose)
+
+ print("Good bye")
+ return super(LibraryLoaderWindow, self).closeEvent(event)
+
+
+def show(
+ debug=False, parent=None, icon=None,
+ show_projects=False, show_libraries=True
+):
+ """Display Loader GUI
+
+ Arguments:
+ debug (bool, optional): Run loader in debug-mode,
+ defaults to False
+ parent (QtCore.QObject, optional): The Qt object to parent to.
+ use_context (bool): Whether to apply the current context upon launch
+
+ """
+ # Remember window
+ if module.window is not None:
+ try:
+ module.window.show()
+
+ # If the window is minimized then unminimize it.
+ if module.window.windowState() & QtCore.Qt.WindowMinimized:
+ module.window.setWindowState(QtCore.Qt.WindowActive)
+
+ # Raise and activate the window
+ module.window.raise_() # for MacOS
+ module.window.activateWindow() # for Windows
+ module.window.refresh()
+ return
+ except RuntimeError as e:
+ if not e.message.rstrip().endswith("already deleted."):
+ raise
+
+ # Garbage collected
+ module.window = None
+
+ if debug:
+ import traceback
+ sys.excepthook = lambda typ, val, tb: traceback.print_last()
+
+ with tools_lib.application():
+ window = LibraryLoaderWindow(
+ parent, icon, show_projects, show_libraries
+ )
+ window.setStyleSheet(style.load_stylesheet())
+ window.show()
+
+ module.window = window
+
+
+def cli(args):
+
+ import argparse
+
+ parser = argparse.ArgumentParser()
+ parser.add_argument("project")
+
+ show(show_projects=True, show_libraries=True)
diff --git a/openpype/tools/libraryloader/lib.py b/openpype/tools/libraryloader/lib.py
new file mode 100644
index 00000000000..6a497a6a16e
--- /dev/null
+++ b/openpype/tools/libraryloader/lib.py
@@ -0,0 +1,33 @@
+import os
+import importlib
+import logging
+from openpype.api import Anatomy
+
+log = logging.getLogger(__name__)
+
+
+# `find_config` from `pipeline`
+def find_config():
+ log.info("Finding configuration for project..")
+
+ config = os.environ["AVALON_CONFIG"]
+
+ if not config:
+ raise EnvironmentError(
+ "No configuration found in "
+ "the project nor environment"
+ )
+
+ log.info("Found %s, loading.." % config)
+ return importlib.import_module(config)
+
+
+class RegisteredRoots:
+ roots_per_project = {}
+
+ @classmethod
+ def registered_root(cls, project_name):
+ if project_name not in cls.roots_per_project:
+ cls.roots_per_project[project_name] = Anatomy(project_name).roots
+
+ return cls.roots_per_project[project_name]
diff --git a/openpype/tools/libraryloader/widgets.py b/openpype/tools/libraryloader/widgets.py
new file mode 100644
index 00000000000..45f9ea2048e
--- /dev/null
+++ b/openpype/tools/libraryloader/widgets.py
@@ -0,0 +1,18 @@
+from Qt import QtWidgets
+
+from .lib import RegisteredRoots
+from openpype.tools.loader.widgets import SubsetWidget
+
+
+class LibrarySubsetWidget(SubsetWidget):
+ def on_copy_source(self):
+ """Copy formatted source path to clipboard"""
+ source = self.data.get("source", None)
+ if not source:
+ return
+
+ project_name = self.dbcon.Session["AVALON_PROJECT"]
+ root = RegisteredRoots.registered_root(project_name)
+ path = source.format(root=root)
+ clipboard = QtWidgets.QApplication.clipboard()
+ clipboard.setText(path)
diff --git a/openpype/tools/loader/__init__.py b/openpype/tools/loader/__init__.py
new file mode 100644
index 00000000000..a5fda8f018e
--- /dev/null
+++ b/openpype/tools/loader/__init__.py
@@ -0,0 +1,11 @@
+from .app import (
+ LoaderWindow,
+ show,
+ cli,
+)
+
+__all__ = (
+ "LoaderWindow",
+ "show",
+ "cli",
+)
diff --git a/openpype/tools/loader/__main__.py b/openpype/tools/loader/__main__.py
new file mode 100644
index 00000000000..146ba7fd10c
--- /dev/null
+++ b/openpype/tools/loader/__main__.py
@@ -0,0 +1,33 @@
+"""Main entrypoint for standalone debugging
+
+ Used for running 'avalon.tool.loader.__main__' as a module (-m), useful for
+ debugging without need to start host.
+
+ Modify AVALON_MONGO accordingly
+"""
+import os
+import sys
+from . import cli
+
+
+def my_exception_hook(exctype, value, traceback):
+ # Print the error and traceback
+ print(exctype, value, traceback)
+ # Call the normal Exception hook after
+ sys._excepthook(exctype, value, traceback)
+ sys.exit(1)
+
+
+if __name__ == '__main__':
+ os.environ["AVALON_MONGO"] = "mongodb://localhost:27017"
+ os.environ["OPENPYPE_MONGO"] = "mongodb://localhost:27017"
+ os.environ["AVALON_DB"] = "avalon"
+ os.environ["AVALON_TIMEOUT"] = "1000"
+ os.environ["OPENPYPE_DEBUG"] = "1"
+ os.environ["AVALON_CONFIG"] = "pype"
+ os.environ["AVALON_ASSET"] = "Jungle"
+
+ # Set the exception hook to our wrapping function
+ sys.excepthook = my_exception_hook
+
+ sys.exit(cli(sys.argv[1:]))
diff --git a/openpype/tools/loader/app.py b/openpype/tools/loader/app.py
new file mode 100644
index 00000000000..5db7a3bcb14
--- /dev/null
+++ b/openpype/tools/loader/app.py
@@ -0,0 +1,674 @@
+import sys
+import time
+
+from Qt import QtWidgets, QtCore
+from avalon import api, io, style, pipeline
+
+from openpype.tools.utils.widgets import AssetWidget
+
+from openpype.tools.utils import lib
+
+from .widgets import (
+ SubsetWidget,
+ VersionWidget,
+ FamilyListWidget,
+ ThumbnailWidget,
+ RepresentationWidget,
+ OverlayFrame
+)
+
+from openpype.modules import ModulesManager
+
+module = sys.modules[__name__]
+module.window = None
+
+
+# Register callback on task change
+# - callback can't be defined in Window as it is weak reference callback
+# so `WeakSet` will remove it immidiatelly
+def on_context_task_change(*args, **kwargs):
+ if module.window:
+ module.window.on_context_task_change(*args, **kwargs)
+
+
+pipeline.on("taskChanged", on_context_task_change)
+
+
+class LoaderWindow(QtWidgets.QDialog):
+ """Asset loader interface"""
+
+ tool_name = "loader"
+
+ def __init__(self, parent=None):
+ super(LoaderWindow, self).__init__(parent)
+ title = "Asset Loader 2.1"
+ project_name = api.Session.get("AVALON_PROJECT")
+ if project_name:
+ title += " - {}".format(project_name)
+ self.setWindowTitle(title)
+
+ # Groups config
+ self.groups_config = lib.GroupsConfig(io)
+ self.family_config_cache = lib.FamilyConfigCache(io)
+
+ # Enable minimize and maximize for app
+ self.setWindowFlags(QtCore.Qt.Window)
+ self.setFocusPolicy(QtCore.Qt.StrongFocus)
+
+ body = QtWidgets.QWidget()
+ footer = QtWidgets.QWidget()
+ footer.setFixedHeight(20)
+
+ container = QtWidgets.QWidget()
+
+ assets = AssetWidget(io, multiselection=True, parent=self)
+ assets.set_current_asset_btn_visibility(True)
+
+ families = FamilyListWidget(io, self.family_config_cache, self)
+ subsets = SubsetWidget(
+ io,
+ self.groups_config,
+ self.family_config_cache,
+ tool_name=self.tool_name,
+ parent=self
+ )
+ version = VersionWidget(io)
+ thumbnail = ThumbnailWidget(io)
+ representations = RepresentationWidget(io, self.tool_name)
+
+ manager = ModulesManager()
+ sync_server = manager.modules_by_name["sync_server"]
+
+ thumb_ver_splitter = QtWidgets.QSplitter()
+ thumb_ver_splitter.setOrientation(QtCore.Qt.Vertical)
+ thumb_ver_splitter.addWidget(thumbnail)
+ thumb_ver_splitter.addWidget(version)
+ if sync_server.enabled:
+ thumb_ver_splitter.addWidget(representations)
+ thumb_ver_splitter.setStretchFactor(0, 30)
+ thumb_ver_splitter.setStretchFactor(1, 35)
+
+ # Create splitter to show / hide family filters
+ asset_filter_splitter = QtWidgets.QSplitter()
+ asset_filter_splitter.setOrientation(QtCore.Qt.Vertical)
+ asset_filter_splitter.addWidget(assets)
+ asset_filter_splitter.addWidget(families)
+ asset_filter_splitter.setStretchFactor(0, 65)
+ asset_filter_splitter.setStretchFactor(1, 35)
+
+ container_layout = QtWidgets.QHBoxLayout(container)
+ container_layout.setContentsMargins(0, 0, 0, 0)
+ split = QtWidgets.QSplitter()
+ split.addWidget(asset_filter_splitter)
+ split.addWidget(subsets)
+ split.addWidget(thumb_ver_splitter)
+
+ container_layout.addWidget(split)
+
+ body_layout = QtWidgets.QHBoxLayout(body)
+ body_layout.addWidget(container)
+ body_layout.setContentsMargins(0, 0, 0, 0)
+
+ message = QtWidgets.QLabel()
+ message.hide()
+
+ footer_layout = QtWidgets.QVBoxLayout(footer)
+ footer_layout.addWidget(message)
+ footer_layout.setContentsMargins(0, 0, 0, 0)
+
+ layout = QtWidgets.QVBoxLayout(self)
+ layout.addWidget(body)
+ layout.addWidget(footer)
+
+ self.data = {
+ "widgets": {
+ "families": families,
+ "assets": assets,
+ "subsets": subsets,
+ "version": version,
+ "thumbnail": thumbnail,
+ "representations": representations
+ },
+ "label": {
+ "message": message,
+ },
+ "state": {
+ "assetIds": None
+ }
+ }
+
+ overlay_frame = OverlayFrame("Loading...", self)
+ overlay_frame.setVisible(False)
+
+ families.active_changed.connect(subsets.set_family_filters)
+ assets.selection_changed.connect(self.on_assetschanged)
+ assets.refresh_triggered.connect(self.on_assetschanged)
+ assets.view.clicked.connect(self.on_assetview_click)
+ subsets.active_changed.connect(self.on_subsetschanged)
+ subsets.version_changed.connect(self.on_versionschanged)
+
+ subsets.load_started.connect(self._on_load_start)
+ subsets.load_ended.connect(self._on_load_end)
+ representations.load_started.connect(self._on_load_start)
+ representations.load_ended.connect(self._on_load_end)
+
+ self._overlay_frame = overlay_frame
+
+ self.family_config_cache.refresh()
+ self.groups_config.refresh()
+
+ self._refresh()
+ self._assetschanged()
+
+ # Defaults
+ if sync_server.enabled:
+ split.setSizes([250, 1000, 550])
+ self.resize(1800, 900)
+ else:
+ split.setSizes([250, 850, 200])
+ self.resize(1300, 700)
+
+ def resizeEvent(self, event):
+ super(LoaderWindow, self).resizeEvent(event)
+ self._overlay_frame.resize(self.size())
+
+ def moveEvent(self, event):
+ super(LoaderWindow, self).moveEvent(event)
+ self._overlay_frame.move(0, 0)
+
+ # -------------------------------
+ # Delay calling blocking methods
+ # -------------------------------
+
+ def on_assetview_click(self, *args):
+ subsets_widget = self.data["widgets"]["subsets"]
+ selection_model = subsets_widget.view.selectionModel()
+ if selection_model.selectedIndexes():
+ selection_model.clearSelection()
+
+ def refresh(self):
+ self.echo("Fetching results..")
+ lib.schedule(self._refresh, 50, channel="mongo")
+
+ def on_assetschanged(self, *args):
+ self.echo("Fetching asset..")
+ lib.schedule(self._assetschanged, 50, channel="mongo")
+
+ def on_subsetschanged(self, *args):
+ self.echo("Fetching subset..")
+ lib.schedule(self._subsetschanged, 50, channel="mongo")
+
+ def on_versionschanged(self, *args):
+ self.echo("Fetching version..")
+ lib.schedule(self._versionschanged, 150, channel="mongo")
+
+ def set_context(self, context, refresh=True):
+ self.echo("Setting context: {}".format(context))
+ lib.schedule(lambda: self._set_context(context, refresh=refresh),
+ 50, channel="mongo")
+
+ def _on_load_start(self):
+ # Show overlay and process events so it's repainted
+ self._overlay_frame.setVisible(True)
+ QtWidgets.QApplication.processEvents()
+
+ def _hide_overlay(self):
+ self._overlay_frame.setVisible(False)
+
+ def _on_load_end(self):
+ # Delay hiding as click events happened during loading should be
+ # blocked
+ QtCore.QTimer.singleShot(100, self._hide_overlay)
+
+ # ------------------------------
+
+ def on_context_task_change(self, *args, **kwargs):
+ # Change to context asset on context change
+ assets_widget = self.data["widgets"]["assets"]
+ assets_widget.select_assets(io.Session["AVALON_ASSET"])
+
+ def _refresh(self):
+ """Load assets from database"""
+
+ # Ensure a project is loaded
+ project = io.find_one({"type": "project"}, {"type": 1})
+ assert project, "Project was not found! This is a bug"
+
+ assets_widget = self.data["widgets"]["assets"]
+ assets_widget.refresh()
+ assets_widget.setFocus()
+
+ families = self.data["widgets"]["families"]
+ families.refresh()
+
+ def clear_assets_underlines(self):
+ """Clear colors from asset data to remove colored underlines
+ When multiple assets are selected colored underlines mark which asset
+ own selected subsets. These colors must be cleared from asset data
+ on selection change so they match current selection.
+ """
+ last_asset_ids = self.data["state"]["assetIds"]
+ if not last_asset_ids:
+ return
+
+ assets_widget = self.data["widgets"]["assets"]
+ id_role = assets_widget.model.ObjectIdRole
+
+ for index in lib.iter_model_rows(assets_widget.model, 0):
+ if index.data(id_role) not in last_asset_ids:
+ continue
+
+ assets_widget.model.setData(
+ index, [], assets_widget.model.subsetColorsRole
+ )
+
+ def _assetschanged(self):
+ """Selected assets have changed"""
+ t1 = time.time()
+
+ assets_widget = self.data["widgets"]["assets"]
+ subsets_widget = self.data["widgets"]["subsets"]
+ subsets_model = subsets_widget.model
+
+ subsets_model.clear()
+ self.clear_assets_underlines()
+
+ # filter None docs they are silo
+ asset_docs = assets_widget.get_selected_assets()
+
+ asset_ids = [asset_doc["_id"] for asset_doc in asset_docs]
+ # Start loading
+ subsets_widget.set_loading_state(
+ loading=bool(asset_ids),
+ empty=True
+ )
+
+ def on_refreshed(has_item):
+ empty = not has_item
+ subsets_widget.set_loading_state(loading=False, empty=empty)
+ subsets_model.refreshed.disconnect()
+ self.echo("Duration: %.3fs" % (time.time() - t1))
+
+ subsets_model.refreshed.connect(on_refreshed)
+
+ subsets_model.set_assets(asset_ids)
+ subsets_widget.view.setColumnHidden(
+ subsets_model.Columns.index("asset"),
+ len(asset_ids) < 2
+ )
+
+ # Clear the version information on asset change
+ self.data["widgets"]["version"].set_version(None)
+ self.data["widgets"]["thumbnail"].set_thumbnail(asset_docs)
+
+ self.data["state"]["assetIds"] = asset_ids
+
+ representations = self.data["widgets"]["representations"]
+ representations.set_version_ids([]) # reset repre list
+
+ def _subsetschanged(self):
+ asset_ids = self.data["state"]["assetIds"]
+ # Skip setting colors if not asset multiselection
+ if not asset_ids or len(asset_ids) < 2:
+ self._versionschanged()
+ return
+
+ subsets = self.data["widgets"]["subsets"]
+ selected_subsets = subsets.selected_subsets(_merged=True, _other=False)
+
+ asset_models = {}
+ asset_ids = []
+ for subset_node in selected_subsets:
+ asset_ids.extend(subset_node.get("assetIds", []))
+ asset_ids = set(asset_ids)
+
+ for subset_node in selected_subsets:
+ for asset_id in asset_ids:
+ if asset_id not in asset_models:
+ asset_models[asset_id] = []
+
+ color = None
+ if asset_id in subset_node.get("assetIds", []):
+ color = subset_node["subsetColor"]
+
+ asset_models[asset_id].append(color)
+
+ self.clear_assets_underlines()
+
+ assets_widget = self.data["widgets"]["assets"]
+ indexes = assets_widget.view.selectionModel().selectedRows()
+
+ for index in indexes:
+ id = index.data(assets_widget.model.ObjectIdRole)
+ if id not in asset_models:
+ continue
+
+ assets_widget.model.setData(
+ index, asset_models[id], assets_widget.model.subsetColorsRole
+ )
+ # Trigger repaint
+ assets_widget.view.updateGeometries()
+ # Set version in Version Widget
+ self._versionschanged()
+
+ def _versionschanged(self):
+ subsets = self.data["widgets"]["subsets"]
+ selection = subsets.view.selectionModel()
+
+ # Active must be in the selected rows otherwise we
+ # assume it's not actually an "active" current index.
+ version_docs = None
+ version_doc = None
+ active = selection.currentIndex()
+ rows = selection.selectedRows(column=active.column())
+ if active:
+ if active in rows:
+ item = active.data(subsets.model.ItemRole)
+ if (
+ item is not None and
+ not (item.get("isGroup") or item.get("isMerged"))
+ ):
+ version_doc = item["version_document"]
+
+ if rows:
+ version_docs = []
+ for index in rows:
+ if not index or not index.isValid():
+ continue
+ item = index.data(subsets.model.ItemRole)
+ if item is None:
+ continue
+ if item.get("isGroup") or item.get("isMerged"):
+ for child in item.children():
+ version_docs.append(child["version_document"])
+ else:
+ version_docs.append(item["version_document"])
+
+ self.data["widgets"]["version"].set_version(version_doc)
+
+ thumbnail_docs = version_docs
+ assets_widget = self.data["widgets"]["assets"]
+ asset_docs = assets_widget.get_selected_assets()
+ if not thumbnail_docs:
+ if len(asset_docs) > 0:
+ thumbnail_docs = asset_docs
+
+ self.data["widgets"]["thumbnail"].set_thumbnail(thumbnail_docs)
+
+ representations = self.data["widgets"]["representations"]
+ version_ids = [doc["_id"] for doc in version_docs or []]
+ representations.set_version_ids(version_ids)
+
+ # representations.change_visibility("subset", len(rows) > 1)
+ # representations.change_visibility("asset", len(asset_docs) > 1)
+
+ def _set_context(self, context, refresh=True):
+ """Set the selection in the interface using a context.
+
+ The context must contain `asset` data by name.
+
+ Note: Prior to setting context ensure `refresh` is triggered so that
+ the "silos" are listed correctly, aside from that setting the
+ context will force a refresh further down because it changes
+ the active silo and asset.
+
+ Args:
+ context (dict): The context to apply.
+
+ Returns:
+ None
+
+ """
+
+ asset = context.get("asset", None)
+ if asset is None:
+ return
+
+ if refresh:
+ # Workaround:
+ # Force a direct (non-scheduled) refresh prior to setting the
+ # asset widget's silo and asset selection to ensure it's correctly
+ # displaying the silo tabs. Calling `window.refresh()` and directly
+ # `window.set_context()` the `set_context()` seems to override the
+ # scheduled refresh and the silo tabs are not shown.
+ self._refresh()
+
+ asset_widget = self.data["widgets"]["assets"]
+ asset_widget.select_assets(asset)
+
+ def echo(self, message):
+ widget = self.data["label"]["message"]
+ widget.setText(str(message))
+ widget.show()
+ print(message)
+
+ lib.schedule(widget.hide, 5000, channel="message")
+
+ def closeEvent(self, event):
+ # Kill on holding SHIFT
+ modifiers = QtWidgets.QApplication.queryKeyboardModifiers()
+ shift_pressed = QtCore.Qt.ShiftModifier & modifiers
+
+ if shift_pressed:
+ print("Force quitted..")
+ self.setAttribute(QtCore.Qt.WA_DeleteOnClose)
+
+ print("Good bye")
+ return super(LoaderWindow, self).closeEvent(event)
+
+ def keyPressEvent(self, event):
+ modifiers = event.modifiers()
+ ctrl_pressed = QtCore.Qt.ControlModifier & modifiers
+
+ # Grouping subsets on pressing Ctrl + G
+ if (ctrl_pressed and event.key() == QtCore.Qt.Key_G and
+ not event.isAutoRepeat()):
+ self.show_grouping_dialog()
+ return
+
+ super(LoaderWindow, self).keyPressEvent(event)
+ event.setAccepted(True) # Avoid interfering other widgets
+
+ def show_grouping_dialog(self):
+ subsets = self.data["widgets"]["subsets"]
+ if not subsets.is_groupable():
+ self.echo("Grouping not enabled.")
+ return
+
+ selected = []
+ merged_items = []
+ for item in subsets.selected_subsets(_merged=True):
+ if item.get("isMerged"):
+ merged_items.append(item)
+ else:
+ selected.append(item)
+
+ for merged_item in merged_items:
+ for child_item in merged_item.children():
+ selected.append(child_item)
+
+ if not selected:
+ self.echo("No selected subset.")
+ return
+
+ dialog = SubsetGroupingDialog(
+ items=selected, groups_config=self.groups_config, parent=self
+ )
+ dialog.grouped.connect(self._assetschanged)
+ dialog.show()
+
+
+class SubsetGroupingDialog(QtWidgets.QDialog):
+ grouped = QtCore.Signal()
+
+ def __init__(self, items, groups_config, parent=None):
+ super(SubsetGroupingDialog, self).__init__(parent=parent)
+ self.setWindowTitle("Grouping Subsets")
+ self.setMinimumWidth(250)
+ self.setModal(True)
+
+ self.items = items
+ self.groups_config = groups_config
+ self.subsets = parent.data["widgets"]["subsets"]
+ self.asset_ids = parent.data["state"]["assetIds"]
+
+ name = QtWidgets.QLineEdit()
+ name.setPlaceholderText("Remain blank to ungroup..")
+
+ # Menu for pre-defined subset groups
+ name_button = QtWidgets.QPushButton()
+ name_button.setFixedWidth(18)
+ name_button.setFixedHeight(20)
+ name_menu = QtWidgets.QMenu(name_button)
+ name_button.setMenu(name_menu)
+
+ name_layout = QtWidgets.QHBoxLayout()
+ name_layout.addWidget(name)
+ name_layout.addWidget(name_button)
+ name_layout.setContentsMargins(0, 0, 0, 0)
+
+ group_btn = QtWidgets.QPushButton("Apply")
+
+ layout = QtWidgets.QVBoxLayout(self)
+ layout.addWidget(QtWidgets.QLabel("Group Name"))
+ layout.addLayout(name_layout)
+ layout.addWidget(group_btn)
+
+ group_btn.clicked.connect(self.on_group)
+ group_btn.setAutoDefault(True)
+ group_btn.setDefault(True)
+
+ self.name = name
+ self.name_menu = name_menu
+
+ self._build_menu()
+
+ def _build_menu(self):
+ menu = self.name_menu
+ button = menu.parent()
+ # Get and destroy the action group
+ group = button.findChild(QtWidgets.QActionGroup)
+ if group:
+ group.deleteLater()
+
+ active_groups = self.groups_config.active_groups(self.asset_ids)
+
+ # Build new action group
+ group = QtWidgets.QActionGroup(button)
+ group_names = list()
+ for data in sorted(active_groups, key=lambda x: x["order"]):
+ name = data["name"]
+ if name in group_names:
+ continue
+ group_names.append(name)
+ icon = data["icon"]
+
+ action = group.addAction(name)
+ action.setIcon(icon)
+ menu.addAction(action)
+
+ group.triggered.connect(self._on_action_clicked)
+ button.setEnabled(not menu.isEmpty())
+
+ def _on_action_clicked(self, action):
+ self.name.setText(action.text())
+
+ def on_group(self):
+ name = self.name.text().strip()
+ self.subsets.group_subsets(name, self.asset_ids, self.items)
+
+ with lib.preserve_selection(tree_view=self.subsets.view,
+ current_index=False):
+ self.grouped.emit()
+ self.close()
+
+
+def show(debug=False, parent=None, use_context=False):
+ """Display Loader GUI
+
+ Arguments:
+ debug (bool, optional): Run loader in debug-mode,
+ defaults to False
+ parent (QtCore.QObject, optional): The Qt object to parent to.
+ use_context (bool): Whether to apply the current context upon launch
+
+ """
+
+ # Remember window
+ if module.window is not None:
+ try:
+ module.window.show()
+
+ # If the window is minimized then unminimize it.
+ if module.window.windowState() & QtCore.Qt.WindowMinimized:
+ module.window.setWindowState(QtCore.Qt.WindowActive)
+
+ # Raise and activate the window
+ module.window.raise_() # for MacOS
+ module.window.activateWindow() # for Windows
+ module.window.refresh()
+ return
+ except (AttributeError, RuntimeError):
+ # Garbage collected
+ module.window = None
+
+ if debug:
+ import traceback
+ sys.excepthook = lambda typ, val, tb: traceback.print_last()
+
+ io.install()
+
+ any_project = next(
+ project for project in io.projects()
+ if project.get("active", True) is not False
+ )
+
+ api.Session["AVALON_PROJECT"] = any_project["name"]
+ module.project = any_project["name"]
+
+ with lib.application():
+ window = LoaderWindow(parent)
+ window.setStyleSheet(style.load_stylesheet())
+ window.show()
+
+ if use_context:
+ context = {"asset": api.Session["AVALON_ASSET"]}
+ window.set_context(context, refresh=True)
+ else:
+ window.refresh()
+
+ module.window = window
+
+ # Pull window to the front.
+ module.window.raise_()
+ module.window.activateWindow()
+
+
+def cli(args):
+
+ import argparse
+
+ parser = argparse.ArgumentParser()
+ parser.add_argument("project")
+
+ args = parser.parse_args(args)
+ project = args.project
+
+ print("Entering Project: %s" % project)
+
+ io.install()
+
+ # Store settings
+ api.Session["AVALON_PROJECT"] = project
+
+ from avalon import pipeline
+
+ # Find the set config
+ _config = pipeline.find_config()
+ if hasattr(_config, "install"):
+ _config.install()
+ else:
+ print("Config `%s` has no function `install`" %
+ _config.__name__)
+
+ show()
diff --git a/openpype/tools/loader/images/default_thumbnail.png b/openpype/tools/loader/images/default_thumbnail.png
new file mode 100644
index 00000000000..97bd958e0da
Binary files /dev/null and b/openpype/tools/loader/images/default_thumbnail.png differ
diff --git a/openpype/tools/loader/lib.py b/openpype/tools/loader/lib.py
new file mode 100644
index 00000000000..14ebab6c859
--- /dev/null
+++ b/openpype/tools/loader/lib.py
@@ -0,0 +1,190 @@
+import inspect
+from Qt import QtGui
+
+from avalon.vendor import qtawesome
+from openpype.tools.utils.widgets import (
+ OptionalAction,
+ OptionDialog
+)
+
+
+def change_visibility(model, view, column_name, visible):
+ """
+ Hides or shows particular 'column_name'.
+
+ "asset" and "subset" columns should be visible only in multiselect
+ """
+ index = model.Columns.index(column_name)
+ view.setColumnHidden(index, not visible)
+
+
+def get_selected_items(rows, item_role):
+ items = []
+ for row_index in rows:
+ item = row_index.data(item_role)
+ if item.get("isGroup"):
+ continue
+
+ elif item.get("isMerged"):
+ for idx in range(row_index.model().rowCount(row_index)):
+ child_index = row_index.child(idx, 0)
+ item = child_index.data(item_role)
+ if item not in items:
+ items.append(item)
+
+ else:
+ if item not in items:
+ items.append(item)
+ return items
+
+
+def get_options(action, loader, parent, repre_contexts):
+ """Provides dialog to select value from loader provided options.
+
+ Loader can provide static or dynamically created options based on
+ qargparse variants.
+
+ Args:
+ action (OptionalAction) - action in menu
+ loader (cls of api.Loader) - not initilized yet
+ parent (Qt element to parent dialog to)
+ repre_contexts (list) of dict with full info about selected repres
+ Returns:
+ (dict) - selected value from OptionDialog
+ None when dialog was closed or cancelled, in all other cases {}
+ if no options
+ """
+ # Pop option dialog
+ options = {}
+ loader_options = loader.get_options(repre_contexts)
+ if getattr(action, "optioned", False) and loader_options:
+ dialog = OptionDialog(parent)
+ dialog.setWindowTitle(action.label + " Options")
+ dialog.create(loader_options)
+
+ if not dialog.exec_():
+ return None
+
+ # Get option
+ options = dialog.parse()
+
+ return options
+
+
+def add_representation_loaders_to_menu(loaders, menu, repre_contexts):
+ """
+ Loops through provider loaders and adds them to 'menu'.
+
+ Expects loaders sorted in requested order.
+ Expects loaders de-duplicated if wanted.
+
+ Args:
+ loaders(tuple): representation - loader
+ menu (OptionalMenu):
+ repre_contexts (dict): full info about representations (contains
+ their repre_doc, asset_doc, subset_doc, version_doc),
+ keys are repre_ids
+
+ Returns:
+ menu (OptionalMenu): with new items
+ """
+ # List the available loaders
+ for representation, loader in loaders:
+ label = None
+ repre_context = None
+ if representation:
+ label = representation.get("custom_label")
+ repre_context = repre_contexts[representation["_id"]]
+
+ if not label:
+ label = get_label_from_loader(loader, representation)
+
+ icon = get_icon_from_loader(loader)
+
+ loader_options = loader.get_options([repre_context])
+
+ use_option = bool(loader_options)
+ action = OptionalAction(label, icon, use_option, menu)
+ if use_option:
+ # Add option box tip
+ action.set_option_tip(loader_options)
+
+ action.setData((representation, loader))
+
+ # Add tooltip and statustip from Loader docstring
+ tip = inspect.getdoc(loader)
+ if tip:
+ action.setToolTip(tip)
+ action.setStatusTip(tip)
+
+ menu.addAction(action)
+
+ return menu
+
+
+def remove_tool_name_from_loaders(available_loaders, tool_name):
+ if not tool_name:
+ return available_loaders
+ filtered_loaders = []
+ for loader in available_loaders:
+ if hasattr(loader, "tool_names"):
+ if not ("*" in loader.tool_names or
+ tool_name in loader.tool_names):
+ continue
+ filtered_loaders.append(loader)
+ return filtered_loaders
+
+
+def get_icon_from_loader(loader):
+ """Pull icon info from loader class"""
+ # Support font-awesome icons using the `.icon` and `.color`
+ # attributes on plug-ins.
+ icon = getattr(loader, "icon", None)
+ if icon is not None:
+ try:
+ key = "fa.{0}".format(icon)
+ color = getattr(loader, "color", "white")
+ icon = qtawesome.icon(key, color=color)
+ except Exception as e:
+ print("Unable to set icon for loader "
+ "{}: {}".format(loader, e))
+ icon = None
+ return icon
+
+
+def get_label_from_loader(loader, representation=None):
+ """Pull label info from loader class"""
+ label = getattr(loader, "label", None)
+ if label is None:
+ label = loader.__name__
+ if representation:
+ # Add the representation as suffix
+ label = "{0} ({1})".format(label, representation['name'])
+ return label
+
+
+def get_no_loader_action(menu, one_item_selected=False):
+ """Creates dummy no loader option in 'menu'"""
+ submsg = "your selection."
+ if one_item_selected:
+ submsg = "this version."
+ msg = "No compatible loaders for {}".format(submsg)
+ print(msg)
+ icon = qtawesome.icon(
+ "fa.exclamation",
+ color=QtGui.QColor(255, 51, 0)
+ )
+ action = OptionalAction(("*" + msg), icon, False, menu)
+ return action
+
+
+def sort_loaders(loaders, custom_sorter=None):
+ def sorter(value):
+ """Sort the Loaders by their order and then their name"""
+ Plugin = value[1]
+ return Plugin.order, Plugin.__name__
+
+ if not custom_sorter:
+ custom_sorter = sorter
+
+ return sorted(loaders, key=custom_sorter)
diff --git a/openpype/tools/loader/model.py b/openpype/tools/loader/model.py
new file mode 100644
index 00000000000..253341f70d4
--- /dev/null
+++ b/openpype/tools/loader/model.py
@@ -0,0 +1,1191 @@
+import copy
+import re
+import math
+
+from avalon import (
+ style,
+ schema
+)
+from Qt import QtCore, QtGui
+
+from avalon.vendor import qtawesome
+from avalon.lib import HeroVersionType
+
+from openpype.tools.utils.models import TreeModel, Item
+from openpype.tools.utils import lib
+
+from openpype.modules import ModulesManager
+
+
+def is_filtering_recursible():
+ """Does Qt binding support recursive filtering for QSortFilterProxyModel?
+
+ (NOTE) Recursive filtering was introduced in Qt 5.10.
+
+ """
+ return hasattr(QtCore.QSortFilterProxyModel,
+ "setRecursiveFilteringEnabled")
+
+
+class BaseRepresentationModel(object):
+ """Methods for SyncServer useful in multiple models"""
+
+ def reset_sync_server(self, project_name=None):
+ """Sets/Resets sync server vars after every change (refresh.)"""
+ repre_icons = {}
+ sync_server = None
+ active_site = active_provider = None
+ remote_site = remote_provider = None
+
+ if not project_name:
+ project_name = self.dbcon.Session["AVALON_PROJECT"]
+ else:
+ self.dbcon.Session["AVALON_PROJECT"] = project_name
+
+ if project_name:
+ manager = ModulesManager()
+ sync_server = manager.modules_by_name["sync_server"]
+
+ if project_name in sync_server.get_enabled_projects():
+ active_site = sync_server.get_active_site(project_name)
+ active_provider = sync_server.get_provider_for_site(
+ project_name, active_site)
+ if active_site == 'studio': # for studio use explicit icon
+ active_provider = 'studio'
+
+ remote_site = sync_server.get_remote_site(project_name)
+ remote_provider = sync_server.get_provider_for_site(
+ project_name, remote_site)
+ if remote_site == 'studio': # for studio use explicit icon
+ remote_provider = 'studio'
+
+ repre_icons = lib.get_repre_icons()
+
+ self.repre_icons = repre_icons
+ self.sync_server = sync_server
+ self.active_site = active_site
+ self.active_provider = active_provider
+ self.remote_site = remote_site
+ self.remote_provider = remote_provider
+
+
+class SubsetsModel(TreeModel, BaseRepresentationModel):
+
+ doc_fetched = QtCore.Signal()
+ refreshed = QtCore.Signal(bool)
+
+ Columns = [
+ "subset",
+ "asset",
+ "family",
+ "version",
+ "time",
+ "author",
+ "frames",
+ "duration",
+ "handles",
+ "step",
+ "repre_info"
+ ]
+
+ column_labels_mapping = {
+ "subset": "Subset",
+ "asset": "Asset",
+ "family": "Family",
+ "version": "Version",
+ "time": "Time",
+ "author": "Author",
+ "frames": "Frames",
+ "duration": "Duration",
+ "handles": "Handles",
+ "step": "Step",
+ "repre_info": "Availability"
+ }
+
+ SortAscendingRole = QtCore.Qt.UserRole + 2
+ SortDescendingRole = QtCore.Qt.UserRole + 3
+ merged_subset_colors = [
+ (55, 161, 222), # Light Blue
+ (231, 176, 0), # Yellow
+ (154, 13, 255), # Purple
+ (130, 184, 30), # Light Green
+ (211, 79, 63), # Light Red
+ (179, 181, 182), # Grey
+ (194, 57, 179), # Pink
+ (0, 120, 215), # Dark Blue
+ (0, 204, 106), # Dark Green
+ (247, 99, 12), # Orange
+ ]
+ not_last_hero_brush = QtGui.QBrush(QtGui.QColor(254, 121, 121))
+
+ # Should be minimum of required asset document keys
+ asset_doc_projection = {
+ "name": 1,
+ "label": 1
+ }
+ # Should be minimum of required subset document keys
+ subset_doc_projection = {
+ "name": 1,
+ "parent": 1,
+ "schema": 1,
+ "families": 1,
+ "data.subsetGroup": 1
+ }
+
+ def __init__(
+ self,
+ dbcon,
+ groups_config,
+ family_config_cache,
+ grouping=True,
+ parent=None,
+ asset_doc_projection=None,
+ subset_doc_projection=None
+ ):
+ super(SubsetsModel, self).__init__(parent=parent)
+
+ self.dbcon = dbcon
+
+ # Projections for Mongo queries
+ # - let ability to modify them if used in tools that require more than
+ # defaults
+ if asset_doc_projection:
+ self.asset_doc_projection = asset_doc_projection
+
+ if subset_doc_projection:
+ self.subset_doc_projection = subset_doc_projection
+
+ self.asset_doc_projection = asset_doc_projection
+ self.subset_doc_projection = subset_doc_projection
+
+ self.repre_icons = {}
+ self.sync_server = None
+ self.active_site = self.active_provider = None
+
+ self.columns_index = dict(
+ (key, idx) for idx, key in enumerate(self.Columns)
+ )
+ self._asset_ids = None
+
+ self.groups_config = groups_config
+ self.family_config_cache = family_config_cache
+ self._sorter = None
+ self._grouping = grouping
+ self._icons = {
+ "subset": qtawesome.icon("fa.file-o", color=style.colors.default)
+ }
+
+ self._doc_fetching_thread = None
+ self._doc_fetching_stop = False
+ self._doc_payload = {}
+
+ self.doc_fetched.connect(self.on_doc_fetched)
+
+ self.refresh()
+
+ def set_assets(self, asset_ids):
+ self._asset_ids = asset_ids
+ self.refresh()
+
+ def set_grouping(self, state):
+ self._grouping = state
+ self.on_doc_fetched()
+
+ def setData(self, index, value, role=QtCore.Qt.EditRole):
+ # Trigger additional edit when `version` column changed
+ # because it also updates the information in other columns
+ if index.column() == self.columns_index["version"]:
+ item = index.internalPointer()
+ parent = item["_id"]
+ if isinstance(value, HeroVersionType):
+ versions = list(self.dbcon.find({
+ "type": {"$in": ["version", "hero_version"]},
+ "parent": parent
+ }, sort=[("name", -1)]))
+
+ version = None
+ last_version = None
+ for __version in versions:
+ if __version["type"] == "hero_version":
+ version = __version
+ elif last_version is None:
+ last_version = __version
+
+ if version is not None and last_version is not None:
+ break
+
+ _version = None
+ for __version in versions:
+ if __version["_id"] == version["version_id"]:
+ _version = __version
+ break
+
+ version["data"] = _version["data"]
+ version["name"] = _version["name"]
+ version["is_from_latest"] = (
+ last_version["_id"] == _version["_id"]
+ )
+
+ else:
+ version = self.dbcon.find_one({
+ "name": value,
+ "type": "version",
+ "parent": parent
+ })
+
+ # update availability on active site when version changes
+ if self.sync_server.enabled and version:
+ site = self.active_site
+ query = self._repre_per_version_pipeline([version["_id"]],
+ site)
+ docs = list(self.dbcon.aggregate(query))
+ if docs:
+ repre = docs.pop()
+ version["data"].update(self._get_repre_dict(repre))
+
+ self.set_version(index, version)
+
+ return super(SubsetsModel, self).setData(index, value, role)
+
+ def set_version(self, index, version):
+ """Update the version data of the given index.
+
+ Arguments:
+ index (QtCore.QModelIndex): The model index.
+ version (dict) Version document in the database.
+
+ """
+
+ assert isinstance(index, QtCore.QModelIndex)
+ if not index.isValid():
+ return
+
+ item = index.internalPointer()
+
+ assert version["parent"] == item["_id"], (
+ "Version does not belong to subset"
+ )
+
+ # Get the data from the version
+ version_data = version.get("data", dict())
+
+ # Compute frame ranges (if data is present)
+ frame_start = version_data.get(
+ "frameStart",
+ # backwards compatibility
+ version_data.get("startFrame", None)
+ )
+ frame_end = version_data.get(
+ "frameEnd",
+ # backwards compatibility
+ version_data.get("endFrame", None)
+ )
+
+ handle_start = version_data.get("handleStart", None)
+ handle_end = version_data.get("handleEnd", None)
+ if handle_start is not None and handle_end is not None:
+ handles = "{}-{}".format(str(handle_start), str(handle_end))
+ else:
+ handles = version_data.get("handles", None)
+
+ if frame_start is not None and frame_end is not None:
+ # Remove superfluous zeros from numbers (3.0 -> 3) to improve
+ # readability for most frame ranges
+ start_clean = ("%f" % frame_start).rstrip("0").rstrip(".")
+ end_clean = ("%f" % frame_end).rstrip("0").rstrip(".")
+ frames = "{0}-{1}".format(start_clean, end_clean)
+ duration = frame_end - frame_start + 1
+ else:
+ frames = None
+ duration = None
+
+ schema_maj_version, _ = schema.get_schema_version(item["schema"])
+ if schema_maj_version < 3:
+ families = version_data.get("families", [None])
+ else:
+ families = item["data"]["families"]
+
+ family = None
+ if families:
+ family = families[0]
+
+ family_config = self.family_config_cache.family_config(family)
+
+ item.update({
+ "version": version["name"],
+ "version_document": version,
+ "author": version_data.get("author", None),
+ "time": version_data.get("time", None),
+ "family": family,
+ "familyLabel": family_config.get("label", family),
+ "familyIcon": family_config.get("icon", None),
+ "families": set(families),
+ "frameStart": frame_start,
+ "frameEnd": frame_end,
+ "duration": duration,
+ "handles": handles,
+ "frames": frames,
+ "step": version_data.get("step", None),
+ })
+
+ repre_info = version_data.get("repre_info")
+ if repre_info:
+ item["repre_info"] = repre_info
+ item["repre_icon"] = version_data.get("repre_icon")
+
+ def _fetch(self):
+ asset_docs = self.dbcon.find(
+ {
+ "type": "asset",
+ "_id": {"$in": self._asset_ids}
+ },
+ self.asset_doc_projection
+ )
+ asset_docs_by_id = {
+ asset_doc["_id"]: asset_doc
+ for asset_doc in asset_docs
+ }
+
+ subset_docs_by_id = {}
+ subset_docs = self.dbcon.find(
+ {
+ "type": "subset",
+ "parent": {"$in": self._asset_ids}
+ },
+ self.subset_doc_projection
+ )
+ for subset in subset_docs:
+ if self._doc_fetching_stop:
+ return
+ subset_docs_by_id[subset["_id"]] = subset
+
+ subset_ids = list(subset_docs_by_id.keys())
+ _pipeline = [
+ # Find all versions of those subsets
+ {"$match": {
+ "type": "version",
+ "parent": {"$in": subset_ids}
+ }},
+ # Sorting versions all together
+ {"$sort": {"name": 1}},
+ # Group them by "parent", but only take the last
+ {"$group": {
+ "_id": "$parent",
+ "_version_id": {"$last": "$_id"},
+ "name": {"$last": "$name"},
+ "type": {"$last": "$type"},
+ "data": {"$last": "$data"},
+ "locations": {"$last": "$locations"},
+ "schema": {"$last": "$schema"}
+ }}
+ ]
+ last_versions_by_subset_id = dict()
+ for doc in self.dbcon.aggregate(_pipeline):
+ if self._doc_fetching_stop:
+ return
+ doc["parent"] = doc["_id"]
+ doc["_id"] = doc.pop("_version_id")
+ last_versions_by_subset_id[doc["parent"]] = doc
+
+ hero_versions = self.dbcon.find({
+ "type": "hero_version",
+ "parent": {"$in": subset_ids}
+ })
+ missing_versions = []
+ for hero_version in hero_versions:
+ version_id = hero_version["version_id"]
+ if version_id not in last_versions_by_subset_id:
+ missing_versions.append(version_id)
+
+ missing_versions_by_id = {}
+ if missing_versions:
+ missing_version_docs = self.dbcon.find({
+ "type": "version",
+ "_id": {"$in": missing_versions}
+ })
+ missing_versions_by_id = {
+ missing_version_doc["_id"]: missing_version_doc
+ for missing_version_doc in missing_version_docs
+ }
+
+ for hero_version in hero_versions:
+ version_id = hero_version["version_id"]
+ subset_id = hero_version["parent"]
+
+ version_doc = last_versions_by_subset_id.get(subset_id)
+ if version_doc is None:
+ version_doc = missing_versions_by_id.get(version_id)
+ if version_doc is None:
+ continue
+
+ hero_version["data"] = version_doc["data"]
+ hero_version["name"] = HeroVersionType(version_doc["name"])
+ # Add information if hero version is from latest version
+ hero_version["is_from_latest"] = version_id == version_doc["_id"]
+
+ last_versions_by_subset_id[subset_id] = hero_version
+
+ self._doc_payload = {
+ "asset_docs_by_id": asset_docs_by_id,
+ "subset_docs_by_id": subset_docs_by_id,
+ "last_versions_by_subset_id": last_versions_by_subset_id
+ }
+
+ if self.sync_server.enabled:
+ version_ids = set()
+ for _subset_id, doc in last_versions_by_subset_id.items():
+ version_ids.add(doc["_id"])
+
+ site = self.active_site
+ query = self._repre_per_version_pipeline(list(version_ids), site)
+
+ repre_info = {}
+ for doc in self.dbcon.aggregate(query):
+ if self._doc_fetching_stop:
+ return
+ doc["provider"] = self.active_provider
+ repre_info[doc["_id"]] = doc
+
+ self._doc_payload["repre_info_by_version_id"] = repre_info
+
+ self.doc_fetched.emit()
+
+ def fetch_subset_and_version(self):
+ """Query all subsets and latest versions from aggregation
+ (NOTE) The returned version documents are NOT the real version
+ document, it's generated from the MongoDB's aggregation so
+ some of the first level field may not be presented.
+ """
+ self._doc_payload = {}
+ self._doc_fetching_stop = False
+ self._doc_fetching_thread = lib.create_qthread(self._fetch)
+ self._doc_fetching_thread.start()
+
+ def stop_fetch_thread(self):
+ if self._doc_fetching_thread is not None:
+ self._doc_fetching_stop = True
+ while self._doc_fetching_thread.isRunning():
+ pass
+
+ def refresh(self):
+ self.stop_fetch_thread()
+ self.clear()
+
+ self.reset_sync_server()
+
+ if not self._asset_ids:
+ self.doc_fetched.emit()
+ return
+
+ self.fetch_subset_and_version()
+
+ def on_doc_fetched(self):
+ self.clear()
+ self.beginResetModel()
+
+ asset_docs_by_id = self._doc_payload.get(
+ "asset_docs_by_id"
+ )
+ subset_docs_by_id = self._doc_payload.get(
+ "subset_docs_by_id"
+ )
+ last_versions_by_subset_id = self._doc_payload.get(
+ "last_versions_by_subset_id"
+ )
+
+ repre_info_by_version_id = self._doc_payload.get(
+ "repre_info_by_version_id"
+ )
+
+ if (
+ asset_docs_by_id is None
+ or subset_docs_by_id is None
+ or last_versions_by_subset_id is None
+ or len(self._asset_ids) == 0
+ ):
+ self.endResetModel()
+ self.refreshed.emit(False)
+ return
+
+ self._fill_subset_items(
+ asset_docs_by_id, subset_docs_by_id, last_versions_by_subset_id,
+ repre_info_by_version_id
+ )
+
+ def create_multiasset_group(
+ self, subset_name, asset_ids, subset_counter, parent_item=None
+ ):
+ subset_color = self.merged_subset_colors[
+ subset_counter % len(self.merged_subset_colors)
+ ]
+ merge_group = Item()
+ merge_group.update({
+ "subset": "{} ({})".format(subset_name, len(asset_ids)),
+ "isMerged": True,
+ "childRow": 0,
+ "subsetColor": subset_color,
+ "assetIds": list(asset_ids),
+ "icon": qtawesome.icon(
+ "fa.circle",
+ color="#{0:02x}{1:02x}{2:02x}".format(*subset_color)
+ )
+ })
+
+ subset_counter += 1
+ self.add_child(merge_group, parent_item)
+
+ return merge_group
+
+ def _fill_subset_items(
+ self, asset_docs_by_id, subset_docs_by_id, last_versions_by_subset_id,
+ repre_info_by_version_id
+ ):
+ _groups_tuple = self.groups_config.split_subsets_for_groups(
+ subset_docs_by_id.values(), self._grouping
+ )
+ groups, subset_docs_without_group, subset_docs_by_group = _groups_tuple
+
+ group_item_by_name = {}
+ for group_data in groups:
+ group_name = group_data["name"]
+ group_item = Item()
+ group_item.update({
+ "subset": group_name,
+ "isGroup": True,
+ "childRow": 0
+ })
+ group_item.update(group_data)
+
+ self.add_child(group_item)
+
+ group_item_by_name[group_name] = {
+ "item": group_item,
+ "index": self.index(group_item.row(), 0)
+ }
+
+ subset_counter = 0
+ for group_name, subset_docs_by_name in subset_docs_by_group.items():
+ parent_item = group_item_by_name[group_name]["item"]
+ parent_index = group_item_by_name[group_name]["index"]
+ for subset_name in sorted(subset_docs_by_name.keys()):
+ subset_docs = subset_docs_by_name[subset_name]
+ asset_ids = [
+ subset_doc["parent"] for subset_doc in subset_docs
+ ]
+ if len(subset_docs) > 1:
+ _parent_item = self.create_multiasset_group(
+ subset_name, asset_ids, subset_counter, parent_item
+ )
+ _parent_index = self.index(
+ _parent_item.row(), 0, parent_index
+ )
+ subset_counter += 1
+ else:
+ _parent_item = parent_item
+ _parent_index = parent_index
+
+ for subset_doc in subset_docs:
+ asset_id = subset_doc["parent"]
+
+ data = copy.deepcopy(subset_doc)
+ data["subset"] = subset_name
+ data["asset"] = asset_docs_by_id[asset_id]["name"]
+
+ last_version = last_versions_by_subset_id.get(
+ subset_doc["_id"]
+ )
+ data["last_version"] = last_version
+
+ # do not show subset without version
+ if not last_version:
+ continue
+
+ data.update(
+ self._get_last_repre_info(repre_info_by_version_id,
+ last_version["_id"]))
+
+ item = Item()
+ item.update(data)
+ self.add_child(item, _parent_item)
+
+ index = self.index(item.row(), 0, _parent_index)
+ self.set_version(index, last_version)
+
+ for subset_name in sorted(subset_docs_without_group.keys()):
+ subset_docs = subset_docs_without_group[subset_name]
+ asset_ids = [subset_doc["parent"] for subset_doc in subset_docs]
+ parent_item = None
+ parent_index = None
+ if len(subset_docs) > 1:
+ parent_item = self.create_multiasset_group(
+ subset_name, asset_ids, subset_counter
+ )
+ parent_index = self.index(parent_item.row(), 0)
+ subset_counter += 1
+
+ for subset_doc in subset_docs:
+ asset_id = subset_doc["parent"]
+
+ data = copy.deepcopy(subset_doc)
+ data["subset"] = subset_name
+ data["asset"] = asset_docs_by_id[asset_id]["name"]
+
+ last_version = last_versions_by_subset_id.get(
+ subset_doc["_id"]
+ )
+ data["last_version"] = last_version
+
+ # do not show subset without version
+ if not last_version:
+ continue
+
+ data.update(
+ self._get_last_repre_info(repre_info_by_version_id,
+ last_version["_id"]))
+
+ item = Item()
+ item.update(data)
+ self.add_child(item, parent_item)
+
+ index = self.index(item.row(), 0, parent_index)
+ self.set_version(index, last_version)
+
+ self.endResetModel()
+ self.refreshed.emit(True)
+
+ def data(self, index, role):
+ if not index.isValid():
+ return
+
+ if role == self.SortDescendingRole:
+ item = index.internalPointer()
+ if item.get("isGroup"):
+ # Ensure groups be on top when sorting by descending order
+ prefix = "2"
+ order = item["order"]
+ else:
+ if item.get("isMerged"):
+ prefix = "1"
+ else:
+ prefix = "0"
+ order = str(super(SubsetsModel, self).data(
+ index, QtCore.Qt.DisplayRole
+ ))
+ return prefix + order
+
+ if role == self.SortAscendingRole:
+ item = index.internalPointer()
+ if item.get("isGroup"):
+ # Ensure groups be on top when sorting by ascending order
+ prefix = "0"
+ order = item["order"]
+ else:
+ if item.get("isMerged"):
+ prefix = "1"
+ else:
+ prefix = "2"
+ order = str(super(SubsetsModel, self).data(
+ index, QtCore.Qt.DisplayRole
+ ))
+ return prefix + order
+
+ if role == QtCore.Qt.DisplayRole:
+ if index.column() == self.columns_index["family"]:
+ # Show familyLabel instead of family
+ item = index.internalPointer()
+ return item.get("familyLabel", None)
+
+ elif role == QtCore.Qt.DecorationRole:
+
+ # Add icon to subset column
+ if index.column() == self.columns_index["subset"]:
+ item = index.internalPointer()
+ if item.get("isGroup") or item.get("isMerged"):
+ return item["icon"]
+ else:
+ return self._icons["subset"]
+
+ # Add icon to family column
+ if index.column() == self.columns_index["family"]:
+ item = index.internalPointer()
+ return item.get("familyIcon", None)
+
+ if index.column() == self.columns_index.get("repre_info"):
+ item = index.internalPointer()
+ return item.get("repre_icon", None)
+
+ elif role == QtCore.Qt.ForegroundRole:
+ item = index.internalPointer()
+ version_doc = item.get("version_document")
+ if version_doc and version_doc.get("type") == "hero_version":
+ if not version_doc["is_from_latest"]:
+ return self.not_last_hero_brush
+
+ return super(SubsetsModel, self).data(index, role)
+
+ def flags(self, index):
+ flags = QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
+
+ # Make the version column editable
+ if index.column() == self.columns_index["version"]:
+ flags |= QtCore.Qt.ItemIsEditable
+
+ return flags
+
+ def headerData(self, section, orientation, role):
+ """Remap column names to labels"""
+ if role == QtCore.Qt.DisplayRole:
+ if section < len(self.Columns):
+ key = self.Columns[section]
+ return self.column_labels_mapping.get(key) or key
+
+ super(TreeModel, self).headerData(section, orientation, role)
+
+ def _get_last_repre_info(self, repre_info_by_version_id, last_version_id):
+ data = {}
+ if repre_info_by_version_id:
+ repre_info = repre_info_by_version_id.get(last_version_id)
+ return self._get_repre_dict(repre_info)
+
+ return data
+
+ def _get_repre_dict(self, repre_info):
+ """Returns icon and str representation of availability"""
+ data = {}
+ if repre_info:
+ repres_str = "{}/{}".format(
+ int(math.floor(float(repre_info['avail_repre']))),
+ int(math.floor(float(repre_info['repre_count']))))
+
+ data["repre_info"] = repres_str
+ data["repre_icon"] = self.repre_icons.get(self.active_provider)
+
+ return data
+
+ def _repre_per_version_pipeline(self, version_ids, site):
+ query = [
+ {"$match": {"parent": {"$in": version_ids},
+ "type": "representation",
+ "files.sites.name": {"$exists": 1}}},
+ {"$unwind": "$files"},
+ {'$addFields': {
+ 'order_local': {
+ '$filter': {'input': '$files.sites', 'as': 'p',
+ 'cond': {'$eq': ['$$p.name', site]}
+ }}
+ }},
+ {'$addFields': {
+ 'progress_local': {"$arrayElemAt": [{
+ '$cond': [{'$size': "$order_local.progress"},
+ "$order_local.progress",
+ # if exists created_dt count is as available
+ {'$cond': [
+ {'$size': "$order_local.created_dt"},
+ [1],
+ [0]
+ ]}
+ ]}, 0]}
+ }},
+ {'$group': { # first group by repre
+ '_id': '$_id',
+ 'parent': {'$first': '$parent'},
+ 'files_count': {'$sum': 1},
+ 'files_avail': {'$sum': "$progress_local"},
+ 'avail_ratio': {'$first': {
+ '$divide': [{'$sum': "$progress_local"}, {'$sum': 1}]}}
+ }},
+ {'$group': { # second group by parent, eg version_id
+ '_id': '$parent',
+ 'repre_count': {'$sum': 1}, # total representations
+ # fully available representation for site
+ 'avail_repre': {'$sum': "$avail_ratio"}
+ }},
+ ]
+ return query
+
+
+class GroupMemberFilterProxyModel(QtCore.QSortFilterProxyModel):
+ """Provide the feature of filtering group by the acceptance of members
+
+ The subset group nodes will not be filtered directly, the group node's
+ acceptance depends on it's child subsets' acceptance.
+
+ """
+
+ if is_filtering_recursible():
+ def _is_group_acceptable(self, index, node):
+ # (NOTE) With the help of `RecursiveFiltering` feature from
+ # Qt 5.10, group always not be accepted by default.
+ return False
+ filter_accepts_group = _is_group_acceptable
+
+ else:
+ # Patch future function
+ setRecursiveFilteringEnabled = (lambda *args: None)
+
+ def _is_group_acceptable(self, index, model):
+ # (NOTE) This is not recursive.
+ for child_row in range(model.rowCount(index)):
+ if self.filterAcceptsRow(child_row, index):
+ return True
+ return False
+ filter_accepts_group = _is_group_acceptable
+
+ def __init__(self, *args, **kwargs):
+ super(GroupMemberFilterProxyModel, self).__init__(*args, **kwargs)
+ self.setRecursiveFilteringEnabled(True)
+
+
+class SubsetFilterProxyModel(GroupMemberFilterProxyModel):
+ def filterAcceptsRow(self, row, parent):
+ model = self.sourceModel()
+ index = model.index(row, self.filterKeyColumn(), parent)
+ item = index.internalPointer()
+ if item.get("isGroup"):
+ return self.filter_accepts_group(index, model)
+ return super(
+ SubsetFilterProxyModel, self
+ ).filterAcceptsRow(row, parent)
+
+
+class FamiliesFilterProxyModel(GroupMemberFilterProxyModel):
+ """Filters to specified families"""
+
+ def __init__(self, family_config_cache, *args, **kwargs):
+ super(FamiliesFilterProxyModel, self).__init__(*args, **kwargs)
+ self._families = set()
+ self.family_config_cache = family_config_cache
+
+ def familyFilter(self):
+ return self._families
+
+ def setFamiliesFilter(self, values):
+ """Set the families to include"""
+ assert isinstance(values, (tuple, list, set))
+ self._families = set(values)
+ self.invalidateFilter()
+
+ def filterAcceptsRow(self, row=0, parent=None):
+ if not self._families:
+ return False
+
+ model = self.sourceModel()
+ index = model.index(row, 0, parent=parent or QtCore.QModelIndex())
+
+ # Ensure index is valid
+ if not index.isValid() or index is None:
+ return True
+
+ # Get the item data and validate
+ item = model.data(index, TreeModel.ItemRole)
+
+ if item.get("isGroup"):
+ return self.filter_accepts_group(index, model)
+
+ family = item.get("family")
+ if not family:
+ return True
+
+ family_config = self.family_config_cache.family_config(family)
+ if family_config.get("hideFilter"):
+ return False
+
+ # We want to keep the families which are not in the list
+ return family in self._families
+
+ def sort(self, column, order):
+ proxy = self.sourceModel()
+ model = proxy.sourceModel()
+ # We need to know the sorting direction for pinning groups on top
+ if order == QtCore.Qt.AscendingOrder:
+ self.setSortRole(model.SortAscendingRole)
+ else:
+ self.setSortRole(model.SortDescendingRole)
+
+ super(FamiliesFilterProxyModel, self).sort(column, order)
+
+
+class RepresentationSortProxyModel(GroupMemberFilterProxyModel):
+ """To properly sort progress string"""
+ def lessThan(self, left, right):
+ source_model = self.sourceModel()
+ progress_indexes = [source_model.Columns.index("active_site"),
+ source_model.Columns.index("remote_site")]
+ if left.column() in progress_indexes:
+ left_data = self.sourceModel().data(left, QtCore.Qt.DisplayRole)
+ right_data = self.sourceModel().data(right, QtCore.Qt.DisplayRole)
+ left_val = re.sub("[^0-9]", '', left_data)
+ right_val = re.sub("[^0-9]", '', right_data)
+
+ return int(left_val) < int(right_val)
+
+ return super(RepresentationSortProxyModel, self).lessThan(left, right)
+
+
+class RepresentationModel(TreeModel, BaseRepresentationModel):
+
+ doc_fetched = QtCore.Signal()
+ refreshed = QtCore.Signal(bool)
+
+ SiteNameRole = QtCore.Qt.UserRole + 2
+ ProgressRole = QtCore.Qt.UserRole + 3
+ SiteSideRole = QtCore.Qt.UserRole + 4
+ IdRole = QtCore.Qt.UserRole + 5
+ ContextRole = QtCore.Qt.UserRole + 6
+
+ Columns = [
+ "name",
+ "subset",
+ "asset",
+ "active_site",
+ "remote_site"
+ ]
+
+ column_labels_mapping = {
+ "name": "Name",
+ "subset": "Subset",
+ "asset": "Asset",
+ "active_site": "Active",
+ "remote_site": "Remote"
+ }
+
+ def __init__(self, dbcon, header, version_ids):
+ super(RepresentationModel, self).__init__()
+ self.dbcon = dbcon
+ self._data = []
+ self._header = header
+ self.version_ids = version_ids
+
+ manager = ModulesManager()
+ sync_server = active_site = remote_site = None
+ active_provider = remote_provider = None
+
+ project = dbcon.Session["AVALON_PROJECT"]
+ if project:
+ sync_server = manager.modules_by_name["sync_server"]
+ active_site = sync_server.get_active_site(project)
+ remote_site = sync_server.get_remote_site(project)
+
+ # TODO refactor
+ active_provider = \
+ sync_server.get_provider_for_site(project,
+ active_site)
+ if active_site == 'studio':
+ active_provider = 'studio'
+
+ remote_provider = \
+ sync_server.get_provider_for_site(project,
+ remote_site)
+
+ if remote_site == 'studio':
+ remote_provider = 'studio'
+
+ self.sync_server = sync_server
+ self.active_site = active_site
+ self.active_provider = active_provider
+ self.remote_site = remote_site
+ self.remote_provider = remote_provider
+
+ self.doc_fetched.connect(self.on_doc_fetched)
+
+ self._docs = {}
+ self._icons = lib.get_repre_icons()
+ self._icons["repre"] = qtawesome.icon("fa.file-o",
+ color=style.colors.default)
+
+ def set_version_ids(self, version_ids):
+ self.version_ids = version_ids
+ self.refresh()
+
+ def data(self, index, role):
+ item = index.internalPointer()
+
+ if role == self.IdRole:
+ return item.get("_id")
+
+ if role == QtCore.Qt.DecorationRole:
+ # Add icon to subset column
+ if index.column() == self.Columns.index("name"):
+ if item.get("isMerged"):
+ return item["icon"]
+ else:
+ return self._icons["repre"]
+
+ active_index = self.Columns.index("active_site")
+ remote_index = self.Columns.index("remote_site")
+ if role == QtCore.Qt.DisplayRole:
+ progress = None
+ label = ''
+ if index.column() == active_index:
+ progress = item.get("active_site_progress", 0)
+ elif index.column() == remote_index:
+ progress = item.get("remote_site_progress", 0)
+
+ if progress is not None:
+ # site added, sync in progress
+ progress_str = "not avail."
+ if progress >= 0:
+ # progress == 0 for isMerged is unavailable
+ if progress == 0 and item.get("isMerged"):
+ progress_str = "not avail."
+ else:
+ progress_str = "{}% {}".format(int(progress * 100),
+ label)
+
+ return progress_str
+
+ if role == QtCore.Qt.DecorationRole:
+ if index.column() == active_index:
+ return item.get("active_site_icon", None)
+ if index.column() == remote_index:
+ return item.get("remote_site_icon", None)
+
+ if role == self.SiteNameRole:
+ if index.column() == active_index:
+ return item.get("active_site_name", None)
+ if index.column() == remote_index:
+ return item.get("remote_site_name", None)
+
+ if role == self.SiteSideRole:
+ if index.column() == active_index:
+ return "active"
+ if index.column() == remote_index:
+ return "remote"
+
+ if role == self.ProgressRole:
+ if index.column() == active_index:
+ return item.get("active_site_progress", 0)
+ if index.column() == remote_index:
+ return item.get("remote_site_progress", 0)
+
+ return super(RepresentationModel, self).data(index, role)
+
+ def on_doc_fetched(self):
+ self.clear()
+ self.beginResetModel()
+ subsets = set()
+ assets = set()
+ repre_groups = {}
+ repre_groups_items = {}
+ group = None
+ self._items_by_id = {}
+ for doc in self._docs:
+ if len(self.version_ids) > 1:
+ group = repre_groups.get(doc["name"])
+ if not group:
+ group_item = Item()
+ group_item.update({
+ "_id": doc["_id"],
+ "name": doc["name"],
+ "isMerged": True,
+ "childRow": 0,
+ "active_site_name": self.active_site,
+ "remote_site_name": self.remote_site,
+ "icon": qtawesome.icon(
+ "fa.folder",
+ color=style.colors.default
+ )
+ })
+ self.add_child(group_item, None)
+ repre_groups[doc["name"]] = group_item
+ repre_groups_items[doc["name"]] = 0
+ group = group_item
+
+ progress = lib.get_progress_for_repre(doc,
+ self.active_site,
+ self.remote_site)
+
+ active_site_icon = self._icons.get(self.active_provider)
+ remote_site_icon = self._icons.get(self.remote_provider)
+
+ data = {
+ "_id": doc["_id"],
+ "name": doc["name"],
+ "subset": doc["context"]["subset"],
+ "asset": doc["context"]["asset"],
+ "isMerged": False,
+
+ "active_site_icon": active_site_icon,
+ "remote_site_icon": remote_site_icon,
+ "active_site_name": self.active_site,
+ "remote_site_name": self.remote_site,
+ "active_site_progress": progress[self.active_site],
+ "remote_site_progress": progress[self.remote_site]
+ }
+ subsets.add(doc["context"]["subset"])
+ assets.add(doc["context"]["subset"])
+
+ item = Item()
+ item.update(data)
+
+ current_progress = {
+ 'active_site_progress': progress[self.active_site],
+ 'remote_site_progress': progress[self.remote_site]
+ }
+ if group:
+ group = self._sum_group_progress(doc["name"], group,
+ current_progress,
+ repre_groups_items)
+
+ self.add_child(item, group)
+
+ # finalize group average progress
+ for group_name, group in repre_groups.items():
+ items_cnt = repre_groups_items[group_name]
+ active_progress = group.get("active_site_progress", 0)
+ group["active_site_progress"] = active_progress / items_cnt
+ remote_progress = group.get("remote_site_progress", 0)
+ group["remote_site_progress"] = remote_progress / items_cnt
+
+ self.endResetModel()
+ self.refreshed.emit(False)
+
+ def refresh(self):
+ docs = []
+ session_project = self.dbcon.Session['AVALON_PROJECT']
+ if not session_project:
+ return
+
+ if self.version_ids:
+ # Simple find here for now, expected to receive lower number of
+ # representations and logic could be in Python
+ docs = list(self.dbcon.find(
+ {"type": "representation", "parent": {"$in": self.version_ids},
+ "files.sites.name": {"$exists": 1}}, self.projection()))
+ self._docs = docs
+
+ self.doc_fetched.emit()
+
+ @classmethod
+ def projection(cls):
+ return {
+ "_id": 1,
+ "name": 1,
+ "context.subset": 1,
+ "context.asset": 1,
+ "context.version": 1,
+ "context.representation": 1,
+ 'files.sites': 1
+ }
+
+ def _sum_group_progress(self, repre_name, group, current_item_progress,
+ repre_groups_items):
+ """
+ Update final group progress
+ Called after every item in group is added
+
+ Args:
+ repre_name(string)
+ group(dict): info about group of selected items
+ current_item_progress(dict): {'active_site_progress': XX,
+ 'remote_site_progress': YY}
+ repre_groups_items(dict)
+ Returns:
+ (dict): updated group info
+ """
+ repre_groups_items[repre_name] += 1
+
+ for key, progress in current_item_progress.items():
+ group[key] = (group.get(key, 0) + max(progress, 0))
+
+ return group
diff --git a/openpype/tools/loader/widgets.py b/openpype/tools/loader/widgets.py
new file mode 100644
index 00000000000..39d162613a8
--- /dev/null
+++ b/openpype/tools/loader/widgets.py
@@ -0,0 +1,1458 @@
+import os
+import sys
+import inspect
+import datetime
+import pprint
+import traceback
+import collections
+
+from Qt import QtWidgets, QtCore, QtGui
+
+from avalon import api, pipeline
+from avalon.lib import HeroVersionType
+
+from openpype.tools.utils import lib as tools_lib
+from openpype.tools.utils.delegates import (
+ VersionDelegate,
+ PrettyTimeDelegate
+)
+from openpype.tools.utils.widgets import OptionalMenu
+from openpype.tools.utils.views import (
+ TreeViewSpinner,
+ DeselectableTreeView
+)
+
+from .model import (
+ SubsetsModel,
+ SubsetFilterProxyModel,
+ FamiliesFilterProxyModel,
+ RepresentationModel,
+ RepresentationSortProxyModel
+)
+from . import lib
+
+
+class OverlayFrame(QtWidgets.QFrame):
+ def __init__(self, label, parent):
+ super(OverlayFrame, self).__init__(parent)
+
+ label_widget = QtWidgets.QLabel(label, self)
+ main_layout = QtWidgets.QVBoxLayout(self)
+ main_layout.addWidget(label_widget, 1, QtCore.Qt.AlignCenter)
+
+ self.label_widget = label_widget
+
+ label_widget.setStyleSheet("background: transparent;")
+ self.setStyleSheet((
+ "background: rgba(0, 0, 0, 127);"
+ "font-size: 60pt;"
+ ))
+
+ def set_label(self, label):
+ self.label_widget.setText(label)
+
+
+class LoadErrorMessageBox(QtWidgets.QDialog):
+ def __init__(self, messages, parent=None):
+ super(LoadErrorMessageBox, self).__init__(parent)
+ self.setWindowTitle("Loading failed")
+ self.setFocusPolicy(QtCore.Qt.StrongFocus)
+
+ body_layout = QtWidgets.QVBoxLayout(self)
+
+ main_label = (
+ "Failed to load items"
+ )
+ main_label_widget = QtWidgets.QLabel(main_label, self)
+ body_layout.addWidget(main_label_widget)
+
+ item_name_template = (
+ "Subset: {}
"
+ "Version: {}
"
+ "Representation: {}
"
+ )
+ exc_msg_template = "{}"
+
+ for exc_msg, tb, repre, subset, version in messages:
+ line = self._create_line()
+ body_layout.addWidget(line)
+
+ item_name = item_name_template.format(subset, version, repre)
+ item_name_widget = QtWidgets.QLabel(
+ item_name.replace("\n", "
"), self
+ )
+ body_layout.addWidget(item_name_widget)
+
+ exc_msg = exc_msg_template.format(exc_msg.replace("\n", "
"))
+ message_label_widget = QtWidgets.QLabel(exc_msg, self)
+ body_layout.addWidget(message_label_widget)
+
+ if tb:
+ tb_widget = QtWidgets.QLabel(tb.replace("\n", "
"), self)
+ tb_widget.setTextInteractionFlags(
+ QtCore.Qt.TextBrowserInteraction
+ )
+ body_layout.addWidget(tb_widget)
+
+ footer_widget = QtWidgets.QWidget(self)
+ footer_layout = QtWidgets.QHBoxLayout(footer_widget)
+ buttonBox = QtWidgets.QDialogButtonBox(QtCore.Qt.Vertical)
+ buttonBox.setStandardButtons(
+ QtWidgets.QDialogButtonBox.StandardButton.Ok
+ )
+ buttonBox.accepted.connect(self._on_accept)
+ footer_layout.addWidget(buttonBox, alignment=QtCore.Qt.AlignRight)
+ body_layout.addWidget(footer_widget)
+
+ def _on_accept(self):
+ self.close()
+
+ def _create_line(self):
+ line = QtWidgets.QFrame(self)
+ line.setFixedHeight(2)
+ line.setFrameShape(QtWidgets.QFrame.HLine)
+ line.setFrameShadow(QtWidgets.QFrame.Sunken)
+ return line
+
+
+class SubsetWidget(QtWidgets.QWidget):
+ """A widget that lists the published subsets for an asset"""
+
+ active_changed = QtCore.Signal() # active index changed
+ version_changed = QtCore.Signal() # version state changed for a subset
+ load_started = QtCore.Signal()
+ load_ended = QtCore.Signal()
+
+ default_widths = (
+ ("subset", 200),
+ ("asset", 130),
+ ("family", 90),
+ ("version", 60),
+ ("time", 125),
+ ("author", 75),
+ ("frames", 75),
+ ("duration", 60),
+ ("handles", 55),
+ ("step", 10),
+ ("repre_info", 65)
+ )
+
+ def __init__(
+ self,
+ dbcon,
+ groups_config,
+ family_config_cache,
+ enable_grouping=True,
+ tool_name=None,
+ parent=None
+ ):
+ super(SubsetWidget, self).__init__(parent=parent)
+
+ self.dbcon = dbcon
+ self.tool_name = tool_name
+
+ model = SubsetsModel(
+ dbcon,
+ groups_config,
+ family_config_cache,
+ grouping=enable_grouping
+ )
+ proxy = SubsetFilterProxyModel()
+ family_proxy = FamiliesFilterProxyModel(family_config_cache)
+ family_proxy.setSourceModel(proxy)
+
+ subset_filter = QtWidgets.QLineEdit()
+ subset_filter.setPlaceholderText("Filter subsets..")
+
+ groupable = QtWidgets.QCheckBox("Enable Grouping")
+ groupable.setChecked(enable_grouping)
+
+ top_bar_layout = QtWidgets.QHBoxLayout()
+ top_bar_layout.addWidget(subset_filter)
+ top_bar_layout.addWidget(groupable)
+
+ view = TreeViewSpinner()
+ view.setObjectName("SubsetView")
+ view.setIndentation(20)
+ view.setStyleSheet("""
+ QTreeView::item{
+ padding: 5px 1px;
+ border: 0px;
+ }
+ """)
+ view.setAllColumnsShowFocus(True)
+
+ # Set view delegates
+ version_delegate = VersionDelegate(self.dbcon)
+ column = model.Columns.index("version")
+ view.setItemDelegateForColumn(column, version_delegate)
+
+ time_delegate = PrettyTimeDelegate()
+ column = model.Columns.index("time")
+ view.setItemDelegateForColumn(column, time_delegate)
+
+ layout = QtWidgets.QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addLayout(top_bar_layout)
+ layout.addWidget(view)
+
+ view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
+ view.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
+ view.setSortingEnabled(True)
+ view.sortByColumn(1, QtCore.Qt.AscendingOrder)
+ view.setAlternatingRowColors(True)
+
+ self.data = {
+ "delegates": {
+ "version": version_delegate,
+ "time": time_delegate
+ },
+ "state": {
+ "groupable": groupable
+ }
+ }
+
+ self.proxy = proxy
+ self.model = model
+ self.view = view
+ self.filter = subset_filter
+ self.family_proxy = family_proxy
+
+ # settings and connections
+ self.proxy.setSourceModel(self.model)
+ self.proxy.setDynamicSortFilter(True)
+ self.proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive)
+
+ self.view.setModel(self.family_proxy)
+ self.view.customContextMenuRequested.connect(self.on_context_menu)
+
+ for column_name, width in self.default_widths:
+ idx = model.Columns.index(column_name)
+ view.setColumnWidth(idx, width)
+
+ actual_project = dbcon.Session["AVALON_PROJECT"]
+ self.on_project_change(actual_project)
+
+ selection = view.selectionModel()
+ selection.selectionChanged.connect(self.active_changed)
+
+ version_delegate.version_changed.connect(self.version_changed)
+
+ groupable.stateChanged.connect(self.set_grouping)
+
+ self.filter.textChanged.connect(self.proxy.setFilterRegExp)
+ self.filter.textChanged.connect(self.view.expandAll)
+
+ self.model.refresh()
+
+ def set_family_filters(self, families):
+ self.family_proxy.setFamiliesFilter(families)
+
+ def is_groupable(self):
+ return self.data["state"]["groupable"].checkState()
+
+ def set_grouping(self, state):
+ with tools_lib.preserve_selection(tree_view=self.view,
+ current_index=False):
+ self.model.set_grouping(state)
+
+ def set_loading_state(self, loading, empty):
+ view = self.view
+
+ if view.is_loading != loading:
+ if loading:
+ view.spinner.repaintNeeded.connect(view.viewport().update)
+ else:
+ view.spinner.repaintNeeded.disconnect()
+
+ view.is_loading = loading
+ view.is_empty = empty
+
+ def _repre_contexts_for_loaders_filter(self, items):
+ version_docs_by_id = {
+ item["version_document"]["_id"]: item["version_document"]
+ for item in items
+ }
+ version_docs_by_subset_id = collections.defaultdict(list)
+ for item in items:
+ subset_id = item["version_document"]["parent"]
+ version_docs_by_subset_id[subset_id].append(
+ item["version_document"]
+ )
+
+ subset_docs = list(self.dbcon.find(
+ {
+ "_id": {"$in": list(version_docs_by_subset_id.keys())},
+ "type": "subset"
+ },
+ {
+ "schema": 1,
+ "data.families": 1
+ }
+ ))
+ subset_docs_by_id = {
+ subset_doc["_id"]: subset_doc
+ for subset_doc in subset_docs
+ }
+ version_ids = list(version_docs_by_id.keys())
+ repre_docs = self.dbcon.find(
+ # Query all representations for selected versions at once
+ {
+ "type": "representation",
+ "parent": {"$in": version_ids}
+ },
+ # Query only name and parent from representation
+ {
+ "name": 1,
+ "parent": 1
+ }
+ )
+ repre_docs_by_version_id = {
+ version_id: []
+ for version_id in version_ids
+ }
+ repre_context_by_id = {}
+ for repre_doc in repre_docs:
+ version_id = repre_doc["parent"]
+ repre_docs_by_version_id[version_id].append(repre_doc)
+
+ version_doc = version_docs_by_id[version_id]
+ repre_context_by_id[repre_doc["_id"]] = {
+ "representation": repre_doc,
+ "version": version_doc,
+ "subset": subset_docs_by_id[version_doc["parent"]]
+ }
+ return repre_context_by_id, repre_docs_by_version_id
+
+ def on_project_change(self, project_name):
+ """
+ Called on each project change in parent widget.
+
+ Checks if Sync Server is enabled for a project, pushes changes to
+ model.
+ """
+ enabled = False
+ if project_name:
+ self.model.reset_sync_server(project_name)
+ if self.model.sync_server:
+ enabled_proj = self.model.sync_server.get_enabled_projects()
+ enabled = project_name in enabled_proj
+
+ lib.change_visibility(self.model, self.view, "repre_info", enabled)
+
+ def on_context_menu(self, point):
+ """Shows menu with loader actions on Right-click.
+
+ Registered actions are filtered by selection and help of
+ `loaders_from_representation` from avalon api. Intersection of actions
+ is shown when more subset is selected. When there are not available
+ actions for selected subsets then special action is shown (works as
+ info message to user): "*No compatible loaders for your selection"
+
+ """
+
+ point_index = self.view.indexAt(point)
+ if not point_index.isValid():
+ return
+
+ # Get selected subsets without groups
+ selection = self.view.selectionModel()
+ rows = selection.selectedRows(column=0)
+
+ items = lib.get_selected_items(rows, self.model.ItemRole)
+
+ # Get all representation->loader combinations available for the
+ # index under the cursor, so we can list the user the options.
+ available_loaders = api.discover(api.Loader)
+ if self.tool_name:
+ available_loaders = lib.remove_tool_name_from_loaders(
+ available_loaders, self.tool_name
+ )
+
+ repre_loaders = []
+ subset_loaders = []
+ for loader in available_loaders:
+ # Skip if its a SubsetLoader.
+ if api.SubsetLoader in inspect.getmro(loader):
+ subset_loaders.append(loader)
+ else:
+ repre_loaders.append(loader)
+
+ loaders = list()
+
+ # Bool if is selected only one subset
+ one_item_selected = (len(items) == 1)
+
+ # Prepare variables for multiple selected subsets
+ first_loaders = []
+ found_combinations = None
+
+ is_first = True
+ repre_context_by_id, repre_docs_by_version_id = (
+ self._repre_contexts_for_loaders_filter(items)
+ )
+ for item in items:
+ _found_combinations = []
+ version_id = item["version_document"]["_id"]
+ repre_docs = repre_docs_by_version_id[version_id]
+ for repre_doc in repre_docs:
+ repre_context = repre_context_by_id[repre_doc["_id"]]
+ for loader in pipeline.loaders_from_repre_context(
+ repre_loaders,
+ repre_context
+ ):
+ # do not allow download whole repre, select specific repre
+ if tools_lib.is_sync_loader(loader):
+ continue
+
+ # skip multiple select variant if one is selected
+ if one_item_selected:
+ loaders.append((repre_doc, loader))
+ continue
+
+ # store loaders of first subset
+ if is_first:
+ first_loaders.append((repre_doc, loader))
+
+ # store combinations to compare with other subsets
+ _found_combinations.append(
+ (repre_doc["name"].lower(), loader)
+ )
+
+ # skip multiple select variant if one is selected
+ if one_item_selected:
+ continue
+
+ is_first = False
+ # Store first combinations to compare
+ if found_combinations is None:
+ found_combinations = _found_combinations
+ # Intersect found combinations with all previous subsets
+ else:
+ found_combinations = list(
+ set(found_combinations) & set(_found_combinations)
+ )
+
+ if not one_item_selected:
+ # Filter loaders from first subset by intersected combinations
+ for repre, loader in first_loaders:
+ if (repre["name"], loader) not in found_combinations:
+ continue
+
+ loaders.append((repre, loader))
+
+ # Subset Loaders.
+ for loader in subset_loaders:
+ loaders.append((None, loader))
+
+ loaders = lib.sort_loaders(loaders)
+
+ # Prepare menu content based on selected items
+ menu = OptionalMenu(self)
+ if not loaders:
+ action = lib.get_no_loader_action(menu, one_item_selected)
+ menu.addAction(action)
+ else:
+ repre_contexts = pipeline.get_repres_contexts(
+ repre_context_by_id.keys(), self.dbcon)
+
+ menu = lib.add_representation_loaders_to_menu(
+ loaders, menu, repre_contexts)
+
+ # Show the context action menu
+ global_point = self.view.mapToGlobal(point)
+ action = menu.exec_(global_point)
+ if not action or not action.data():
+ return
+
+ # Find the representation name and loader to trigger
+ action_representation, loader = action.data()
+
+ self.load_started.emit()
+
+ if api.SubsetLoader in inspect.getmro(loader):
+ subset_ids = []
+ subset_version_docs = {}
+ for item in items:
+ subset_id = item["version_document"]["parent"]
+ subset_ids.append(subset_id)
+ subset_version_docs[subset_id] = item["version_document"]
+
+ # get contexts only for selected menu option
+ subset_contexts_by_id = pipeline.get_subset_contexts(subset_ids,
+ self.dbcon)
+ subset_contexts = list(subset_contexts_by_id.values())
+ options = lib.get_options(action, loader, self, subset_contexts)
+
+ error_info = _load_subsets_by_loader(
+ loader, subset_contexts, options, subset_version_docs
+ )
+
+ else:
+ representation_name = action_representation["name"]
+
+ # Run the loader for all selected indices, for those that have the
+ # same representation available
+
+ # Trigger
+ repre_ids = []
+ for item in items:
+ representation = self.dbcon.find_one(
+ {
+ "type": "representation",
+ "name": representation_name,
+ "parent": item["version_document"]["_id"]
+ },
+ {"_id": 1}
+ )
+ if not representation:
+ self.echo("Subset '{}' has no representation '{}'".format(
+ item["subset"], representation_name
+ ))
+ continue
+ repre_ids.append(representation["_id"])
+
+ # get contexts only for selected menu option
+ repre_contexts = pipeline.get_repres_contexts(repre_ids,
+ self.dbcon)
+ options = lib.get_options(action, loader, self,
+ list(repre_contexts.values()))
+
+ error_info = _load_representations_by_loader(
+ loader, repre_contexts, options=options
+ )
+
+ self.load_ended.emit()
+
+ if error_info:
+ box = LoadErrorMessageBox(error_info)
+ box.show()
+
+ def selected_subsets(self, _groups=False, _merged=False, _other=True):
+ selection = self.view.selectionModel()
+ rows = selection.selectedRows(column=0)
+
+ subsets = list()
+ if not any([_groups, _merged, _other]):
+ self.echo((
+ "This is a BUG: Selected_subsets args must contain"
+ " at least one value set to True"
+ ))
+ return subsets
+
+ for row in rows:
+ item = row.data(self.model.ItemRole)
+ if item.get("isGroup"):
+ if not _groups:
+ continue
+
+ elif item.get("isMerged"):
+ if not _merged:
+ continue
+ else:
+ if not _other:
+ continue
+
+ subsets.append(item)
+
+ return subsets
+
+ def group_subsets(self, name, asset_ids, items):
+ field = "data.subsetGroup"
+
+ if name:
+ update = {"$set": {field: name}}
+ self.echo("Group subsets to '%s'.." % name)
+ else:
+ update = {"$unset": {field: ""}}
+ self.echo("Ungroup subsets..")
+
+ subsets = list()
+ for item in items:
+ subsets.append(item["subset"])
+
+ for asset_id in asset_ids:
+ filtr = {
+ "type": "subset",
+ "parent": asset_id,
+ "name": {"$in": subsets},
+ }
+ self.dbcon.update_many(filtr, update)
+
+ def echo(self, message):
+ print(message)
+
+
+class VersionTextEdit(QtWidgets.QTextEdit):
+ """QTextEdit that displays version specific information.
+
+ This also overrides the context menu to add actions like copying
+ source path to clipboard or copying the raw data of the version
+ to clipboard.
+
+ """
+ def __init__(self, dbcon, parent=None):
+ super(VersionTextEdit, self).__init__(parent=parent)
+ self.dbcon = dbcon
+
+ self.data = {
+ "source": None,
+ "raw": None
+ }
+
+ # Reset
+ self.set_version(None)
+
+ def set_version(self, version_doc=None, version_id=None):
+ # TODO expect only filling data (do not query them here!)
+ if not version_doc and not version_id:
+ # Reset state to empty
+ self.data = {
+ "source": None,
+ "raw": None,
+ }
+ self.setText("")
+ self.setEnabled(True)
+ return
+
+ self.setEnabled(True)
+
+ print("Querying..")
+
+ if not version_doc:
+ version_doc = self.dbcon.find_one({
+ "_id": version_id,
+ "type": {"$in": ["version", "hero_version"]}
+ })
+ assert version_doc, "Not a valid version id"
+
+ if version_doc["type"] == "hero_version":
+ _version_doc = self.dbcon.find_one({
+ "_id": version_doc["version_id"],
+ "type": "version"
+ })
+ version_doc["data"] = _version_doc["data"]
+ version_doc["name"] = HeroVersionType(
+ _version_doc["name"]
+ )
+
+ subset = self.dbcon.find_one({
+ "_id": version_doc["parent"],
+ "type": "subset"
+ })
+ assert subset, "No valid subset parent for version"
+
+ # Define readable creation timestamp
+ created = version_doc["data"]["time"]
+ created = datetime.datetime.strptime(created, "%Y%m%dT%H%M%SZ")
+ created = datetime.datetime.strftime(created, "%b %d %Y %H:%M")
+
+ comment = version_doc["data"].get("comment", None) or "No comment"
+
+ source = version_doc["data"].get("source", None)
+ source_label = source if source else "No source"
+
+ # Store source and raw data
+ self.data["source"] = source
+ self.data["raw"] = version_doc
+
+ if version_doc["type"] == "hero_version":
+ version_name = "hero"
+ else:
+ version_name = tools_lib.format_version(version_doc["name"])
+
+ data = {
+ "subset": subset["name"],
+ "version": version_name,
+ "comment": comment,
+ "created": created,
+ "source": source_label
+ }
+
+ self.setHtml((
+ "
{subset}
"
+ "{version}
"
+ "Comment
"
+ "{comment}
"
+
+ "Created
"
+ "{created}
"
+
+ "Source
"
+ "{source}"
+ ).format(**data))
+
+ def contextMenuEvent(self, event):
+ """Context menu with additional actions"""
+ menu = self.createStandardContextMenu()
+
+ # Add additional actions when any text so we can assume
+ # the version is set.
+ if self.toPlainText().strip():
+
+ menu.addSeparator()
+ action = QtWidgets.QAction("Copy source path to clipboard",
+ menu)
+ action.triggered.connect(self.on_copy_source)
+ menu.addAction(action)
+
+ action = QtWidgets.QAction("Copy raw data to clipboard",
+ menu)
+ action.triggered.connect(self.on_copy_raw)
+ menu.addAction(action)
+
+ menu.exec_(event.globalPos())
+ del menu
+
+ def on_copy_source(self):
+ """Copy formatted source path to clipboard"""
+ source = self.data.get("source", None)
+ if not source:
+ return
+
+ path = source.format(root=api.registered_root())
+ clipboard = QtWidgets.QApplication.clipboard()
+ clipboard.setText(path)
+
+ def on_copy_raw(self):
+ """Copy raw version data to clipboard
+
+ The data is string formatted with `pprint.pformat`.
+
+ """
+ raw = self.data.get("raw", None)
+ if not raw:
+ return
+
+ raw_text = pprint.pformat(raw)
+ clipboard = QtWidgets.QApplication.clipboard()
+ clipboard.setText(raw_text)
+
+
+class ThumbnailWidget(QtWidgets.QLabel):
+
+ aspect_ratio = (16, 9)
+ max_width = 300
+
+ def __init__(self, dbcon, parent=None):
+ super(ThumbnailWidget, self).__init__(parent)
+ self.dbcon = dbcon
+
+ self.current_thumb_id = None
+ self.current_thumbnail = None
+
+ self.setAlignment(QtCore.Qt.AlignCenter)
+
+ # TODO get res path much better way
+ default_pix_path = os.path.join(
+ os.path.dirname(os.path.abspath(__file__)),
+ "images",
+ "default_thumbnail.png"
+ )
+ self.default_pix = QtGui.QPixmap(default_pix_path)
+
+ def height(self):
+ width = self.width()
+ asp_w, asp_h = self.aspect_ratio
+
+ return (width / asp_w) * asp_h
+
+ def width(self):
+ width = super(ThumbnailWidget, self).width()
+ if width > self.max_width:
+ width = self.max_width
+ return width
+
+ def set_pixmap(self, pixmap=None):
+ if not pixmap:
+ pixmap = self.default_pix
+ self.current_thumb_id = None
+
+ self.current_thumbnail = pixmap
+
+ pixmap = self.scale_pixmap(pixmap)
+ self.setPixmap(pixmap)
+
+ def resizeEvent(self, _event):
+ if not self.current_thumbnail:
+ return
+ cur_pix = self.scale_pixmap(self.current_thumbnail)
+ self.setPixmap(cur_pix)
+
+ def scale_pixmap(self, pixmap):
+ return pixmap.scaled(
+ self.width(), self.height(), QtCore.Qt.KeepAspectRatio
+ )
+
+ def set_thumbnail(self, entity=None):
+ if not entity:
+ self.set_pixmap()
+ return
+
+ if isinstance(entity, (list, tuple)):
+ if len(entity) == 1:
+ entity = entity[0]
+ else:
+ self.set_pixmap()
+ return
+
+ thumbnail_id = entity.get("data", {}).get("thumbnail_id")
+ if thumbnail_id == self.current_thumb_id:
+ if self.current_thumbnail is None:
+ self.set_pixmap()
+ return
+
+ self.current_thumb_id = thumbnail_id
+ if not thumbnail_id:
+ self.set_pixmap()
+ return
+
+ thumbnail_ent = self.dbcon.find_one(
+ {"type": "thumbnail", "_id": thumbnail_id}
+ )
+ if not thumbnail_ent:
+ return
+
+ thumbnail_bin = pipeline.get_thumbnail_binary(
+ thumbnail_ent, "thumbnail", self.dbcon
+ )
+ if not thumbnail_bin:
+ self.set_pixmap()
+ return
+
+ thumbnail = QtGui.QPixmap()
+ thumbnail.loadFromData(thumbnail_bin)
+
+ self.set_pixmap(thumbnail)
+
+
+class VersionWidget(QtWidgets.QWidget):
+ """A Widget that display information about a specific version"""
+ def __init__(self, dbcon, parent=None):
+ super(VersionWidget, self).__init__(parent=parent)
+
+ layout = QtWidgets.QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ label = QtWidgets.QLabel("Version", self)
+ data = VersionTextEdit(dbcon, self)
+ data.setReadOnly(True)
+
+ layout.addWidget(label)
+ layout.addWidget(data)
+
+ self.data = data
+
+ def set_version(self, version_doc):
+ self.data.set_version(version_doc)
+
+
+class FamilyListWidget(QtWidgets.QListWidget):
+ """A Widget that lists all available families"""
+
+ NameRole = QtCore.Qt.UserRole + 1
+ active_changed = QtCore.Signal(list)
+
+ def __init__(self, dbcon, family_config_cache, parent=None):
+ super(FamilyListWidget, self).__init__(parent=parent)
+
+ self.family_config_cache = family_config_cache
+ self.dbcon = dbcon
+
+ multi_select = QtWidgets.QAbstractItemView.ExtendedSelection
+ self.setSelectionMode(multi_select)
+ self.setAlternatingRowColors(True)
+ # Enable RMB menu
+ self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
+ self.customContextMenuRequested.connect(self.show_right_mouse_menu)
+
+ self.itemChanged.connect(self._on_item_changed)
+
+ def refresh(self):
+ """Refresh the listed families.
+
+ This gets all unique families and adds them as checkable items to
+ the list.
+
+ """
+
+ families = []
+ if self.dbcon.Session.get("AVALON_PROJECT"):
+ result = list(self.dbcon.aggregate([
+ {"$match": {
+ "type": "subset"
+ }},
+ {"$project": {
+ "family": {"$arrayElemAt": ["$data.families", 0]}
+ }},
+ {"$group": {
+ "_id": "family_group",
+ "families": {"$addToSet": "$family"}
+ }}
+ ]))
+ if result:
+ families = result[0]["families"]
+
+ # Rebuild list
+ self.blockSignals(True)
+ self.clear()
+ for name in sorted(families):
+ family = self.family_config_cache.family_config(name)
+ if family.get("hideFilter"):
+ continue
+
+ label = family.get("label", name)
+ icon = family.get("icon", None)
+
+ # TODO: This should be more managable by the artist
+ # Temporarily implement support for a default state in the project
+ # configuration
+ state = family.get("state", True)
+ state = QtCore.Qt.Checked if state else QtCore.Qt.Unchecked
+
+ item = QtWidgets.QListWidgetItem(parent=self)
+ item.setText(label)
+ item.setFlags(item.flags() | QtCore.Qt.ItemIsUserCheckable)
+ item.setData(self.NameRole, name)
+ item.setCheckState(state)
+
+ if icon:
+ item.setIcon(icon)
+
+ self.addItem(item)
+ self.blockSignals(False)
+
+ self.active_changed.emit(self.get_filters())
+
+ def get_filters(self):
+ """Return the checked family items"""
+
+ items = [self.item(i) for i in
+ range(self.count())]
+
+ return [item.data(self.NameRole) for item in items if
+ item.checkState() == QtCore.Qt.Checked]
+
+ def _on_item_changed(self):
+ self.active_changed.emit(self.get_filters())
+
+ def _set_checkstate_all(self, state):
+ _state = QtCore.Qt.Checked if state is True else QtCore.Qt.Unchecked
+ self.blockSignals(True)
+ for i in range(self.count()):
+ item = self.item(i)
+ item.setCheckState(_state)
+ self.blockSignals(False)
+ self.active_changed.emit(self.get_filters())
+
+ def show_right_mouse_menu(self, pos):
+ """Build RMB menu under mouse at current position (within widget)"""
+
+ # Get mouse position
+ globalpos = self.viewport().mapToGlobal(pos)
+
+ menu = QtWidgets.QMenu(self)
+
+ # Add enable all action
+ state_checked = QtWidgets.QAction(menu, text="Enable All")
+ state_checked.triggered.connect(
+ lambda: self._set_checkstate_all(True))
+ # Add disable all action
+ state_unchecked = QtWidgets.QAction(menu, text="Disable All")
+ state_unchecked.triggered.connect(
+ lambda: self._set_checkstate_all(False))
+
+ menu.addAction(state_checked)
+ menu.addAction(state_unchecked)
+
+ menu.exec_(globalpos)
+
+
+class RepresentationWidget(QtWidgets.QWidget):
+ load_started = QtCore.Signal()
+ load_ended = QtCore.Signal()
+
+ default_widths = (
+ ("name", 120),
+ ("subset", 125),
+ ("asset", 125),
+ ("active_site", 85),
+ ("remote_site", 85)
+ )
+
+ commands = {'active': 'Download', 'remote': 'Upload'}
+
+ def __init__(self, dbcon, tool_name=None, parent=None):
+ super(RepresentationWidget, self).__init__(parent=parent)
+ self.dbcon = dbcon
+ self.tool_name = tool_name
+
+ headers = [item[0] for item in self.default_widths]
+
+ model = RepresentationModel(self.dbcon, headers, [])
+
+ proxy_model = RepresentationSortProxyModel(self)
+ proxy_model.setSourceModel(model)
+
+ label = QtWidgets.QLabel("Representations", self)
+
+ tree_view = DeselectableTreeView()
+ tree_view.setModel(proxy_model)
+ tree_view.setAllColumnsShowFocus(True)
+ tree_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
+ tree_view.setSelectionMode(
+ QtWidgets.QAbstractItemView.ExtendedSelection)
+ tree_view.setSortingEnabled(True)
+ tree_view.sortByColumn(1, QtCore.Qt.AscendingOrder)
+ tree_view.setAlternatingRowColors(True)
+ tree_view.setIndentation(20)
+ tree_view.setStyleSheet("""
+ QTreeView::item{
+ padding: 5px 1px;
+ border: 0px;
+ }
+ """)
+ tree_view.collapseAll()
+
+ for column_name, width in self.default_widths:
+ idx = model.Columns.index(column_name)
+ tree_view.setColumnWidth(idx, width)
+
+ layout = QtWidgets.QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addWidget(label)
+ layout.addWidget(tree_view)
+
+ # self.itemChanged.connect(self._on_item_changed)
+ tree_view.customContextMenuRequested.connect(self.on_context_menu)
+
+ self.tree_view = tree_view
+ self.model = model
+ self.proxy_model = proxy_model
+
+ self.sync_server_enabled = False
+ actual_project = dbcon.Session["AVALON_PROJECT"]
+ self.on_project_change(actual_project)
+
+ self.model.refresh()
+
+ def on_project_change(self, project_name):
+ """
+ Called on each project change in parent widget.
+
+ Checks if Sync Server is enabled for a project, pushes changes to
+ model.
+ """
+ enabled = False
+ if project_name:
+ self.model.reset_sync_server(project_name)
+ if self.model.sync_server:
+ enabled_proj = self.model.sync_server.get_enabled_projects()
+ enabled = project_name in enabled_proj
+
+ self.sync_server_enabled = enabled
+ lib.change_visibility(self.model, self.tree_view,
+ "active_site", enabled)
+ lib.change_visibility(self.model, self.tree_view,
+ "remote_site", enabled)
+
+ def _repre_contexts_for_loaders_filter(self, items):
+ repre_ids = []
+ for item in items:
+ repre_ids.append(item["_id"])
+
+ repre_docs = list(self.dbcon.find(
+ {
+ "type": "representation",
+ "_id": {"$in": repre_ids}
+ },
+ {
+ "name": 1,
+ "parent": 1
+ }
+ ))
+ version_ids = [
+ repre_doc["parent"]
+ for repre_doc in repre_docs
+ ]
+ version_docs = self.dbcon.find({
+ "_id": {"$in": version_ids}
+ })
+
+ version_docs_by_id = {}
+ version_docs_by_subset_id = collections.defaultdict(list)
+ for version_doc in version_docs:
+ version_id = version_doc["_id"]
+ subset_id = version_doc["parent"]
+ version_docs_by_id[version_id] = version_doc
+ version_docs_by_subset_id[subset_id].append(version_doc)
+
+ subset_docs = list(self.dbcon.find(
+ {
+ "_id": {"$in": list(version_docs_by_subset_id.keys())},
+ "type": "subset"
+ },
+ {
+ "schema": 1,
+ "data.families": 1
+ }
+ ))
+ subset_docs_by_id = {
+ subset_doc["_id"]: subset_doc
+ for subset_doc in subset_docs
+ }
+ repre_context_by_id = {}
+ for repre_doc in repre_docs:
+ version_id = repre_doc["parent"]
+
+ version_doc = version_docs_by_id[version_id]
+ repre_context_by_id[repre_doc["_id"]] = {
+ "representation": repre_doc,
+ "version": version_doc,
+ "subset": subset_docs_by_id[version_doc["parent"]]
+ }
+ return repre_context_by_id
+
+ def on_context_menu(self, point):
+ """Shows menu with loader actions on Right-click.
+
+ Registered actions are filtered by selection and help of
+ `loaders_from_representation` from avalon api. Intersection of actions
+ is shown when more subset is selected. When there are not available
+ actions for selected subsets then special action is shown (works as
+ info message to user): "*No compatible loaders for your selection"
+
+ """
+ point_index = self.tree_view.indexAt(point)
+ if not point_index.isValid():
+ return
+
+ # Get selected subsets without groups
+ selection = self.tree_view.selectionModel()
+ rows = selection.selectedRows(column=0)
+
+ items = lib.get_selected_items(rows, self.model.ItemRole)
+
+ selected_side = self._get_selected_side(point_index, rows)
+
+ # Get all representation->loader combinations available for the
+ # index under the cursor, so we can list the user the options.
+ available_loaders = api.discover(api.Loader)
+
+ filtered_loaders = []
+ for loader in available_loaders:
+ # Skip subset loaders
+ if api.SubsetLoader in inspect.getmro(loader):
+ continue
+
+ if (
+ tools_lib.is_sync_loader(loader)
+ and not self.sync_server_enabled
+ ):
+ continue
+
+ filtered_loaders.append(loader)
+
+ if self.tool_name:
+ filtered_loaders = lib.remove_tool_name_from_loaders(
+ filtered_loaders, self.tool_name
+ )
+
+ loaders = list()
+ already_added_loaders = set()
+ label_already_in_menu = set()
+
+ repre_context_by_id = (
+ self._repre_contexts_for_loaders_filter(items)
+ )
+
+ for item in items:
+ repre_context = repre_context_by_id[item["_id"]]
+ for loader in pipeline.loaders_from_repre_context(
+ filtered_loaders,
+ repre_context
+ ):
+ if tools_lib.is_sync_loader(loader):
+ both_unavailable = (
+ item["active_site_progress"] <= 0
+ and item["remote_site_progress"] <= 0
+ )
+ if both_unavailable:
+ continue
+
+ for selected_side in self.commands.keys():
+ item = item.copy()
+ item["custom_label"] = None
+ label = None
+ selected_site_progress = item.get(
+ "{}_site_progress".format(selected_side), -1)
+
+ # only remove if actually present
+ if tools_lib.is_remove_site_loader(loader):
+ label = "Remove {}".format(selected_side)
+ if selected_site_progress < 1:
+ continue
+
+ if tools_lib.is_add_site_loader(loader):
+ label = self.commands[selected_side]
+ if selected_site_progress >= 0:
+ label = 'Re-{} {}'.format(label, selected_side)
+
+ if not label:
+ continue
+
+ item["selected_side"] = selected_side
+ item["custom_label"] = label
+
+ if label not in label_already_in_menu:
+ loaders.append((item, loader))
+ already_added_loaders.add(loader)
+ label_already_in_menu.add(label)
+
+ else:
+ item = item.copy()
+ item["custom_label"] = None
+
+ if loader not in already_added_loaders:
+ loaders.append((item, loader))
+ already_added_loaders.add(loader)
+
+ loaders = lib.sort_loaders(loaders)
+
+ menu = OptionalMenu(self)
+ if not loaders:
+ action = lib.get_no_loader_action(menu)
+ menu.addAction(action)
+ else:
+ repre_contexts = pipeline.get_repres_contexts(
+ repre_context_by_id.keys(), self.dbcon)
+ menu = lib.add_representation_loaders_to_menu(loaders, menu,
+ repre_contexts)
+
+ self._process_action(items, menu, point)
+
+ def _process_action(self, items, menu, point):
+ """
+ Show the context action menu and process selected
+
+ Args:
+ items(dict): menu items
+ menu(OptionalMenu)
+ point(PointIndex)
+ """
+ global_point = self.tree_view.mapToGlobal(point)
+ action = menu.exec_(global_point)
+
+ if not action or not action.data():
+ return
+
+ self.load_started.emit()
+
+ # Find the representation name and loader to trigger
+ action_representation, loader = action.data()
+ repre_ids = []
+ data_by_repre_id = {}
+ selected_side = action_representation.get("selected_side")
+
+ for item in items:
+ if tools_lib.is_sync_loader(loader):
+ site_name = "{}_site_name".format(selected_side)
+ data = {
+ "_id": item.get("_id"),
+ "site_name": item.get(site_name),
+ "project_name": self.dbcon.Session["AVALON_PROJECT"]
+ }
+
+ if not data["site_name"]:
+ continue
+
+ data_by_repre_id[data["_id"]] = data
+
+ repre_ids.append(item.get("_id"))
+
+ repre_contexts = pipeline.get_repres_contexts(repre_ids,
+ self.dbcon)
+ options = lib.get_options(action, loader, self,
+ list(repre_contexts.values()))
+
+ errors = _load_representations_by_loader(
+ loader, repre_contexts,
+ options=options, data_by_repre_id=data_by_repre_id)
+
+ self.model.refresh()
+
+ self.load_ended.emit()
+
+ if errors:
+ box = LoadErrorMessageBox(errors)
+ box.show()
+
+ def _get_optional_labels(self, loaders, selected_side):
+ """Each loader could have specific label
+
+ Args:
+ loaders (tuple of dict, dict): (item, loader)
+ selected_side(string): active or remote
+
+ Returns:
+ (dict) {loader: string}
+ """
+ optional_labels = {}
+ if selected_side:
+ if selected_side == 'active':
+ txt = "Localize"
+ else:
+ txt = "Sync to Remote"
+ optional_labels = {loader: txt for _, loader in loaders
+ if tools_lib.is_sync_loader(loader)}
+ return optional_labels
+
+ def _get_selected_side(self, point_index, rows):
+ """Returns active/remote label according to column in 'point_index'"""
+ selected_side = None
+ if self.sync_server_enabled:
+ if rows:
+ source_index = self.proxy_model.mapToSource(point_index)
+ selected_side = self.model.data(source_index,
+ self.model.SiteSideRole)
+ return selected_side
+
+ def set_version_ids(self, version_ids):
+ self.model.set_version_ids(version_ids)
+
+ def _set_download(self):
+ pass
+
+ def change_visibility(self, column_name, visible):
+ """
+ Hides or shows particular 'column_name'.
+
+ "asset" and "subset" columns should be visible only in multiselect
+ """
+ lib.change_visibility(self.model, self.tree_view, column_name, visible)
+
+
+def _load_representations_by_loader(loader, repre_contexts,
+ options,
+ data_by_repre_id=None):
+ """Loops through list of repre_contexts and loads them with one loader
+
+ Args:
+ loader (cls of api.Loader) - not initialized yet
+ repre_contexts (dicts) - full info about selected representations
+ (containing repre_doc, version_doc, subset_doc, project info)
+ options (dict) - qargparse arguments to fill OptionDialog
+ data_by_repre_id (dict) - additional data applicable on top of
+ options to provide dynamic values
+ """
+ error_info = []
+
+ if options is None: # not load when cancelled
+ return
+
+ for repre_context in repre_contexts.values():
+ try:
+ if data_by_repre_id:
+ _id = repre_context["representation"]["_id"]
+ data = data_by_repre_id.get(_id)
+ options.update(data)
+ pipeline.load_with_repre_context(
+ loader,
+ repre_context,
+ options=options
+ )
+ except pipeline.IncompatibleLoaderError as exc:
+ print(exc)
+ error_info.append((
+ "Incompatible Loader",
+ None,
+ repre_context["representation"]["name"],
+ repre_context["subset"]["name"],
+ repre_context["version"]["name"]
+ ))
+
+ except Exception as exc:
+ exc_type, exc_value, exc_traceback = sys.exc_info()
+ formatted_traceback = "".join(traceback.format_exception(
+ exc_type, exc_value, exc_traceback
+ ))
+ error_info.append((
+ str(exc),
+ formatted_traceback,
+ repre_context["representation"]["name"],
+ repre_context["subset"]["name"],
+ repre_context["version"]["name"]
+ ))
+ return error_info
+
+
+def _load_subsets_by_loader(loader, subset_contexts, options,
+ subset_version_docs=None):
+ """
+ Triggers load with SubsetLoader type of loaders
+
+ Args:
+ loader (SubsetLoder):
+ subset_contexts (list):
+ options (dict):
+ subset_version_docs (dict): {subset_id: version_doc}
+ """
+ error_info = []
+
+ if options is None: # not load when cancelled
+ return
+
+ if loader.is_multiple_contexts_compatible:
+ subset_names = []
+ for context in subset_contexts:
+ subset_name = context.get("subset", {}).get("name") or "N/A"
+ subset_names.append(subset_name)
+
+ context["version"] = subset_version_docs[context["subset"]["_id"]]
+ try:
+ pipeline.load_with_subset_contexts(
+ loader,
+ subset_contexts,
+ options=options
+ )
+ except Exception as exc:
+ exc_type, exc_value, exc_traceback = sys.exc_info()
+ formatted_traceback = "".join(
+ traceback.format_exception(
+ exc_type, exc_value, exc_traceback
+ )
+ )
+ error_info.append((
+ str(exc),
+ formatted_traceback,
+ None,
+ ", ".join(subset_names),
+ None
+ ))
+ else:
+ for subset_context in subset_contexts:
+ subset_name = subset_context.get("subset", {}).get("name") or "N/A"
+
+ version_doc = subset_version_docs[subset_context["subset"]["_id"]]
+ subset_context["version"] = version_doc
+ try:
+ pipeline.load_with_subset_context(
+ loader,
+ subset_context,
+ options=options
+ )
+ except Exception as exc:
+ exc_type, exc_value, exc_traceback = sys.exc_info()
+ formatted_traceback = "\n".join(
+ traceback.format_exception(
+ exc_type, exc_value, exc_traceback
+ )
+ )
+ error_info.append((
+ str(exc),
+ formatted_traceback,
+ None,
+ subset_name,
+ None
+ ))
+
+ return error_info
diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/openpype/tools/utils/delegates.py b/openpype/tools/utils/delegates.py
new file mode 100644
index 00000000000..1827bc7e9be
--- /dev/null
+++ b/openpype/tools/utils/delegates.py
@@ -0,0 +1,449 @@
+import time
+from datetime import datetime
+import logging
+import numbers
+
+import Qt
+from Qt import QtWidgets, QtGui, QtCore
+
+from avalon.lib import HeroVersionType
+from .models import (
+ AssetModel,
+ TreeModel
+)
+from . import lib
+
+if Qt.__binding__ == "PySide":
+ from PySide.QtGui import QStyleOptionViewItemV4
+elif Qt.__binding__ == "PyQt4":
+ from PyQt4.QtGui import QStyleOptionViewItemV4
+
+log = logging.getLogger(__name__)
+
+
+class AssetDelegate(QtWidgets.QItemDelegate):
+ bar_height = 3
+
+ def sizeHint(self, option, index):
+ result = super(AssetDelegate, self).sizeHint(option, index)
+ height = result.height()
+ result.setHeight(height + self.bar_height)
+
+ return result
+
+ def paint(self, painter, option, index):
+ # Qt4 compat
+ if Qt.__binding__ in ("PySide", "PyQt4"):
+ option = QStyleOptionViewItemV4(option)
+
+ painter.save()
+
+ item_rect = QtCore.QRect(option.rect)
+ item_rect.setHeight(option.rect.height() - self.bar_height)
+
+ subset_colors = index.data(AssetModel.subsetColorsRole)
+ subset_colors_width = 0
+ if subset_colors:
+ subset_colors_width = option.rect.width() / len(subset_colors)
+
+ subset_rects = []
+ counter = 0
+ for subset_c in subset_colors:
+ new_color = None
+ new_rect = None
+ if subset_c:
+ new_color = QtGui.QColor(*subset_c)
+
+ new_rect = QtCore.QRect(
+ option.rect.left() + (counter * subset_colors_width),
+ option.rect.top() + (
+ option.rect.height() - self.bar_height
+ ),
+ subset_colors_width,
+ self.bar_height
+ )
+ subset_rects.append((new_color, new_rect))
+ counter += 1
+
+ # Background
+ bg_color = QtGui.QColor(60, 60, 60)
+ if option.state & QtWidgets.QStyle.State_Selected:
+ if len(subset_colors) == 0:
+ item_rect.setTop(item_rect.top() + (self.bar_height / 2))
+ if option.state & QtWidgets.QStyle.State_MouseOver:
+ bg_color.setRgb(70, 70, 70)
+ else:
+ item_rect.setTop(item_rect.top() + (self.bar_height / 2))
+ if option.state & QtWidgets.QStyle.State_MouseOver:
+ bg_color.setAlpha(100)
+ else:
+ bg_color.setAlpha(0)
+
+ # When not needed to do a rounded corners (easier and without
+ # painter restore):
+ # painter.fillRect(
+ # item_rect,
+ # QtGui.QBrush(bg_color)
+ # )
+ pen = painter.pen()
+ pen.setStyle(QtCore.Qt.NoPen)
+ pen.setWidth(0)
+ painter.setPen(pen)
+ painter.setBrush(QtGui.QBrush(bg_color))
+ painter.drawRoundedRect(option.rect, 3, 3)
+
+ if option.state & QtWidgets.QStyle.State_Selected:
+ for color, subset_rect in subset_rects:
+ if not color or not subset_rect:
+ continue
+ painter.fillRect(subset_rect, QtGui.QBrush(color))
+
+ painter.restore()
+ painter.save()
+
+ # Icon
+ icon_index = index.model().index(
+ index.row(), index.column(), index.parent()
+ )
+ # - Default icon_rect if not icon
+ icon_rect = QtCore.QRect(
+ item_rect.left(),
+ item_rect.top(),
+ # To make sure it's same size all the time
+ option.rect.height() - self.bar_height,
+ option.rect.height() - self.bar_height
+ )
+ icon = index.model().data(icon_index, QtCore.Qt.DecorationRole)
+
+ if icon:
+ mode = QtGui.QIcon.Normal
+ if not (option.state & QtWidgets.QStyle.State_Enabled):
+ mode = QtGui.QIcon.Disabled
+ elif option.state & QtWidgets.QStyle.State_Selected:
+ mode = QtGui.QIcon.Selected
+
+ if isinstance(icon, QtGui.QPixmap):
+ icon = QtGui.QIcon(icon)
+ option.decorationSize = icon.size() / icon.devicePixelRatio()
+
+ elif isinstance(icon, QtGui.QColor):
+ pixmap = QtGui.QPixmap(option.decorationSize)
+ pixmap.fill(icon)
+ icon = QtGui.QIcon(pixmap)
+
+ elif isinstance(icon, QtGui.QImage):
+ icon = QtGui.QIcon(QtGui.QPixmap.fromImage(icon))
+ option.decorationSize = icon.size() / icon.devicePixelRatio()
+
+ elif isinstance(icon, QtGui.QIcon):
+ state = QtGui.QIcon.Off
+ if option.state & QtWidgets.QStyle.State_Open:
+ state = QtGui.QIcon.On
+ actualSize = option.icon.actualSize(
+ option.decorationSize, mode, state
+ )
+ option.decorationSize = QtCore.QSize(
+ min(option.decorationSize.width(), actualSize.width()),
+ min(option.decorationSize.height(), actualSize.height())
+ )
+
+ state = QtGui.QIcon.Off
+ if option.state & QtWidgets.QStyle.State_Open:
+ state = QtGui.QIcon.On
+
+ icon.paint(
+ painter, icon_rect,
+ QtCore.Qt.AlignLeft, mode, state
+ )
+
+ # Text
+ text_rect = QtCore.QRect(
+ icon_rect.left() + icon_rect.width() + 2,
+ item_rect.top(),
+ item_rect.width(),
+ item_rect.height()
+ )
+
+ painter.drawText(
+ text_rect, QtCore.Qt.AlignVCenter,
+ index.data(QtCore.Qt.DisplayRole)
+ )
+
+ painter.restore()
+
+
+class VersionDelegate(QtWidgets.QStyledItemDelegate):
+ """A delegate that display version integer formatted as version string."""
+
+ version_changed = QtCore.Signal()
+ first_run = False
+ lock = False
+
+ def __init__(self, dbcon, *args, **kwargs):
+ self.dbcon = dbcon
+ super(VersionDelegate, self).__init__(*args, **kwargs)
+
+ def displayText(self, value, locale):
+ if isinstance(value, HeroVersionType):
+ return lib.format_version(value, True)
+ assert isinstance(value, numbers.Integral), (
+ "Version is not integer. \"{}\" {}".format(value, str(type(value)))
+ )
+ return lib.format_version(value)
+
+ def paint(self, painter, option, index):
+ fg_color = index.data(QtCore.Qt.ForegroundRole)
+ if fg_color:
+ if isinstance(fg_color, QtGui.QBrush):
+ fg_color = fg_color.color()
+ elif isinstance(fg_color, QtGui.QColor):
+ pass
+ else:
+ fg_color = None
+
+ if not fg_color:
+ return super(VersionDelegate, self).paint(painter, option, index)
+
+ if option.widget:
+ style = option.widget.style()
+ else:
+ style = QtWidgets.QApplication.style()
+
+ style.drawControl(
+ style.CE_ItemViewItem, option, painter, option.widget
+ )
+
+ painter.save()
+
+ text = self.displayText(
+ index.data(QtCore.Qt.DisplayRole), option.locale
+ )
+ pen = painter.pen()
+ pen.setColor(fg_color)
+ painter.setPen(pen)
+
+ text_rect = style.subElementRect(style.SE_ItemViewItemText, option)
+ text_margin = style.proxy().pixelMetric(
+ style.PM_FocusFrameHMargin, option, option.widget
+ ) + 1
+
+ painter.drawText(
+ text_rect.adjusted(text_margin, 0, - text_margin, 0),
+ option.displayAlignment,
+ text
+ )
+
+ painter.restore()
+
+ def createEditor(self, parent, option, index):
+ item = index.data(TreeModel.ItemRole)
+ if item.get("isGroup") or item.get("isMerged"):
+ return
+
+ editor = QtWidgets.QComboBox(parent)
+
+ def commit_data():
+ if not self.first_run:
+ self.commitData.emit(editor) # Update model data
+ self.version_changed.emit() # Display model data
+ editor.currentIndexChanged.connect(commit_data)
+
+ self.first_run = True
+ self.lock = False
+
+ return editor
+
+ def setEditorData(self, editor, index):
+ if self.lock:
+ # Only set editor data once per delegation
+ return
+
+ editor.clear()
+
+ # Current value of the index
+ item = index.data(TreeModel.ItemRole)
+ value = index.data(QtCore.Qt.DisplayRole)
+ if item["version_document"]["type"] != "hero_version":
+ assert isinstance(value, numbers.Integral), (
+ "Version is not integer"
+ )
+
+ # Add all available versions to the editor
+ parent_id = item["version_document"]["parent"]
+ version_docs = list(self.dbcon.find(
+ {
+ "type": "version",
+ "parent": parent_id
+ },
+ sort=[("name", 1)]
+ ))
+
+ hero_version_doc = self.dbcon.find_one(
+ {
+ "type": "hero_version",
+ "parent": parent_id
+ }, {
+ "name": 1,
+ "data.tags": 1,
+ "version_id": 1
+ }
+ )
+
+ doc_for_hero_version = None
+
+ selected = None
+ items = []
+ for version_doc in version_docs:
+ version_tags = version_doc["data"].get("tags") or []
+ if "deleted" in version_tags:
+ continue
+
+ if (
+ hero_version_doc
+ and doc_for_hero_version is None
+ and hero_version_doc["version_id"] == version_doc["_id"]
+ ):
+ doc_for_hero_version = version_doc
+
+ label = lib.format_version(version_doc["name"])
+ item = QtGui.QStandardItem(label)
+ item.setData(version_doc, QtCore.Qt.UserRole)
+ items.append(item)
+
+ if version_doc["name"] == value:
+ selected = item
+
+ if hero_version_doc and doc_for_hero_version:
+ version_name = doc_for_hero_version["name"]
+ label = lib.format_version(version_name, True)
+ if isinstance(value, HeroVersionType):
+ index = len(version_docs)
+ hero_version_doc["name"] = HeroVersionType(version_name)
+
+ item = QtGui.QStandardItem(label)
+ item.setData(hero_version_doc, QtCore.Qt.UserRole)
+ items.append(item)
+
+ # Reverse items so latest versions be upper
+ items = list(reversed(items))
+ for item in items:
+ editor.model().appendRow(item)
+
+ index = 0
+ if selected:
+ index = selected.row()
+
+ # Will trigger index-change signal
+ editor.setCurrentIndex(index)
+ self.first_run = False
+ self.lock = True
+
+ def setModelData(self, editor, model, index):
+ """Apply the integer version back in the model"""
+ version = editor.itemData(editor.currentIndex())
+ model.setData(index, version["name"])
+
+
+def pretty_date(t, now=None, strftime="%b %d %Y %H:%M"):
+ """Parse datetime to readable timestamp
+
+ Within first ten seconds:
+ - "just now",
+ Within first minute ago:
+ - "%S seconds ago"
+ Within one hour ago:
+ - "%M minutes ago".
+ Within one day ago:
+ - "%H:%M hours ago"
+ Else:
+ "%Y-%m-%d %H:%M:%S"
+
+ """
+
+ assert isinstance(t, datetime)
+ if now is None:
+ now = datetime.now()
+ assert isinstance(now, datetime)
+ diff = now - t
+
+ second_diff = diff.seconds
+ day_diff = diff.days
+
+ # future (consider as just now)
+ if day_diff < 0:
+ return "just now"
+
+ # history
+ if day_diff == 0:
+ if second_diff < 10:
+ return "just now"
+ if second_diff < 60:
+ return str(second_diff) + " seconds ago"
+ if second_diff < 120:
+ return "a minute ago"
+ if second_diff < 3600:
+ return str(second_diff // 60) + " minutes ago"
+ if second_diff < 86400:
+ minutes = (second_diff % 3600) // 60
+ hours = second_diff // 3600
+ return "{0}:{1:02d} hours ago".format(hours, minutes)
+
+ return t.strftime(strftime)
+
+
+def pretty_timestamp(t, now=None):
+ """Parse timestamp to user readable format
+
+ >>> pretty_timestamp("20170614T151122Z", now="20170614T151123Z")
+ 'just now'
+
+ >>> pretty_timestamp("20170614T151122Z", now="20170614T171222Z")
+ '2:01 hours ago'
+
+ Args:
+ t (str): The time string to parse.
+ now (str, optional)
+
+ Returns:
+ str: human readable "recent" date.
+
+ """
+
+ if now is not None:
+ try:
+ now = time.strptime(now, "%Y%m%dT%H%M%SZ")
+ now = datetime.fromtimestamp(time.mktime(now))
+ except ValueError as e:
+ log.warning("Can't parse 'now' time format: {0} {1}".format(t, e))
+ return None
+
+ if isinstance(t, float):
+ dt = datetime.fromtimestamp(t)
+ else:
+ # Parse the time format as if it is `str` result from
+ # `pyblish.lib.time()` which usually is stored in Avalon database.
+ try:
+ t = time.strptime(t, "%Y%m%dT%H%M%SZ")
+ except ValueError as e:
+ log.warning("Can't parse time format: {0} {1}".format(t, e))
+ return None
+ dt = datetime.fromtimestamp(time.mktime(t))
+
+ # prettify
+ return pretty_date(dt, now=now)
+
+
+class PrettyTimeDelegate(QtWidgets.QStyledItemDelegate):
+ """A delegate that displays a timestamp as a pretty date.
+
+ This displays dates like `pretty_date`.
+
+ """
+
+ def displayText(self, value, locale):
+
+ if value is None:
+ # Ignore None value
+ return
+
+ return pretty_timestamp(value)
diff --git a/openpype/tools/utils/lib.py b/openpype/tools/utils/lib.py
new file mode 100644
index 00000000000..e83f663b2ea
--- /dev/null
+++ b/openpype/tools/utils/lib.py
@@ -0,0 +1,622 @@
+import os
+import sys
+import contextlib
+import collections
+
+from Qt import QtWidgets, QtCore, QtGui
+
+from avalon import io, api, style
+from avalon.vendor import qtawesome
+
+self = sys.modules[__name__]
+self._jobs = dict()
+
+
+class SharedObjects:
+ # Variable for family cache in global context
+ # QUESTION is this safe? More than one tool can refresh at the same time.
+ family_cache = None
+
+
+def global_family_cache():
+ if SharedObjects.family_cache is None:
+ SharedObjects.family_cache = FamilyConfigCache(io)
+ return SharedObjects.family_cache
+
+
+def format_version(value, hero_version=False):
+ """Formats integer to displayable version name"""
+ label = "v{0:03d}".format(value)
+ if not hero_version:
+ return label
+ return "[{}]".format(label)
+
+
+@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
+
+
+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 synchonous.
+
+ 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 schedule(func, time, channel="default"):
+ """Run `func` at a later `time` in a dedicated `channel`
+
+ Given an arbitrary function, call this function after a given
+ timeout. It will ensure that only one "job" is running within
+ the given channel at any one time and cancel any currently
+ running job if a new job is submitted before the timeout.
+
+ """
+
+ try:
+ self._jobs[channel].stop()
+ except (AttributeError, KeyError, RuntimeError):
+ pass
+
+ timer = QtCore.QTimer()
+ timer.setSingleShot(True)
+ timer.timeout.connect(func)
+ timer.start(time)
+
+ self._jobs[channel] = timer
+
+
+@contextlib.contextmanager
+def dummy():
+ """Dummy context manager
+
+ Usage:
+ >> with some_context() if False else dummy():
+ .. pass
+
+ """
+
+ yield
+
+
+def iter_model_rows(model, column, include_root=False):
+ """Iterate over all row indices in a model"""
+ indices = [QtCore.QModelIndex()] # start iteration at root
+
+ for index in indices:
+ # Add children to the iterations
+ child_rows = model.rowCount(index)
+ for child_row in range(child_rows):
+ child_index = model.index(child_row, column, index)
+ indices.append(child_index)
+
+ if not include_root and not index.isValid():
+ continue
+
+ yield index
+
+
+@contextlib.contextmanager
+def preserve_states(tree_view,
+ column=0,
+ role=None,
+ preserve_expanded=True,
+ preserve_selection=True,
+ expanded_role=QtCore.Qt.DisplayRole,
+ selection_role=QtCore.Qt.DisplayRole):
+ """Preserves row selection in QTreeView by column's data role.
+ This function is created to maintain the selection status of
+ the model items. When refresh is triggered the items which are expanded
+ will stay expanded and vise versa.
+ tree_view (QWidgets.QTreeView): the tree view nested in the application
+ column (int): the column to retrieve the data from
+ role (int): the role which dictates what will be returned
+ Returns:
+ None
+ """
+ # When `role` is set then override both expanded and selection roles
+ if role:
+ expanded_role = role
+ selection_role = role
+
+ model = tree_view.model()
+ selection_model = tree_view.selectionModel()
+ flags = selection_model.Select | selection_model.Rows
+
+ expanded = set()
+
+ if preserve_expanded:
+ for index in iter_model_rows(
+ model, column=column, include_root=False
+ ):
+ if tree_view.isExpanded(index):
+ value = index.data(expanded_role)
+ expanded.add(value)
+
+ selected = None
+
+ if preserve_selection:
+ selected_rows = selection_model.selectedRows()
+ if selected_rows:
+ selected = set(row.data(selection_role) for row in selected_rows)
+
+ try:
+ yield
+ finally:
+ if expanded:
+ for index in iter_model_rows(
+ model, column=0, include_root=False
+ ):
+ value = index.data(expanded_role)
+ is_expanded = value in expanded
+ # skip if new index was created meanwhile
+ if is_expanded is None:
+ continue
+ tree_view.setExpanded(index, is_expanded)
+
+ if selected:
+ # Go through all indices, select the ones with similar data
+ for index in iter_model_rows(
+ model, column=column, include_root=False
+ ):
+ value = index.data(selection_role)
+ state = value in selected
+ if state:
+ tree_view.scrollTo(index) # Ensure item is visible
+ selection_model.select(index, flags)
+
+
+@contextlib.contextmanager
+def preserve_expanded_rows(tree_view, column=0, role=None):
+ """Preserves expanded row in QTreeView by column's data role.
+
+ This function is created to maintain the expand vs collapse status of
+ the model items. When refresh is triggered the items which are expanded
+ will stay expanded and vise versa.
+
+ Arguments:
+ tree_view (QWidgets.QTreeView): the tree view which is
+ nested in the application
+ column (int): the column to retrieve the data from
+ role (int): the role which dictates what will be returned
+
+ Returns:
+ None
+
+ """
+ if role is None:
+ role = QtCore.Qt.DisplayRole
+ model = tree_view.model()
+
+ expanded = set()
+
+ for index in iter_model_rows(model, column=column, include_root=False):
+ if tree_view.isExpanded(index):
+ value = index.data(role)
+ expanded.add(value)
+
+ try:
+ yield
+ finally:
+ if not expanded:
+ return
+
+ for index in iter_model_rows(model, column=column, include_root=False):
+ value = index.data(role)
+ state = value in expanded
+ if state:
+ tree_view.expand(index)
+ else:
+ tree_view.collapse(index)
+
+
+@contextlib.contextmanager
+def preserve_selection(tree_view, column=0, role=None, current_index=True):
+ """Preserves row selection in QTreeView by column's data role.
+
+ This function is created to maintain the selection status of
+ the model items. When refresh is triggered the items which are expanded
+ will stay expanded and vise versa.
+
+ tree_view (QWidgets.QTreeView): the tree view nested in the application
+ column (int): the column to retrieve the data from
+ role (int): the role which dictates what will be returned
+
+ Returns:
+ None
+
+ """
+ if role is None:
+ role = QtCore.Qt.DisplayRole
+ model = tree_view.model()
+ selection_model = tree_view.selectionModel()
+ flags = selection_model.Select | selection_model.Rows
+
+ if current_index:
+ current_index_value = tree_view.currentIndex().data(role)
+ else:
+ current_index_value = None
+
+ selected_rows = selection_model.selectedRows()
+ if not selected_rows:
+ yield
+ return
+
+ selected = set(row.data(role) for row in selected_rows)
+ try:
+ yield
+ finally:
+ if not selected:
+ return
+
+ # Go through all indices, select the ones with similar data
+ for index in iter_model_rows(model, column=column, include_root=False):
+ value = index.data(role)
+ state = value in selected
+ if state:
+ tree_view.scrollTo(index) # Ensure item is visible
+ selection_model.select(index, flags)
+
+ if current_index_value and value == current_index_value:
+ selection_model.setCurrentIndex(
+ index, selection_model.NoUpdate
+ )
+
+
+class FamilyConfigCache:
+ default_color = "#0091B2"
+ _default_icon = None
+ _default_item = None
+
+ def __init__(self, dbcon):
+ self.dbcon = dbcon
+ self.family_configs = {}
+
+ @classmethod
+ def default_icon(cls):
+ if cls._default_icon is None:
+ cls._default_icon = qtawesome.icon(
+ "fa.folder", color=cls.default_color
+ )
+ return cls._default_icon
+
+ @classmethod
+ def default_item(cls):
+ if cls._default_item is None:
+ cls._default_item = {"icon": cls.default_icon()}
+ return cls._default_item
+
+ def family_config(self, family_name):
+ """Get value from config with fallback to default"""
+ return self.family_configs.get(family_name, self.default_item())
+
+ def refresh(self):
+ """Get the family configurations from the database
+
+ The configuration must be stored on the project under `config`.
+ For example:
+
+ {"config": {
+ "families": [
+ {"name": "avalon.camera", label: "Camera", "icon": "photo"},
+ {"name": "avalon.anim", label: "Animation", "icon": "male"},
+ ]
+ }}
+
+ It is possible to override the default behavior and set specific
+ families checked. For example we only want the families imagesequence
+ and camera to be visible in the Loader.
+
+ # This will turn every item off
+ api.data["familyStateDefault"] = False
+
+ # Only allow the imagesequence and camera
+ api.data["familyStateToggled"] = ["imagesequence", "camera"]
+
+ """
+
+ self.family_configs.clear()
+
+ families = []
+
+ # Update the icons from the project configuration
+ project_name = self.dbcon.Session.get("AVALON_PROJECT")
+ if project_name:
+ project_doc = self.dbcon.find_one(
+ {"type": "project"},
+ projection={"config.families": True}
+ )
+
+ if not project_doc:
+ print((
+ "Project \"{}\" not found!"
+ " Can't refresh family icons cache."
+ ).format(project_name))
+ else:
+ families = project_doc["config"].get("families") or []
+
+ # Check if any family state are being overwritten by the configuration
+ default_state = api.data.get("familiesStateDefault", True)
+ toggled = set(api.data.get("familiesStateToggled") or [])
+
+ # Replace icons with a Qt icon we can use in the user interfaces
+ for family in families:
+ name = family["name"]
+ # Set family icon
+ icon = family.get("icon", None)
+ if icon:
+ family["icon"] = qtawesome.icon(
+ "fa.{}".format(icon),
+ color=self.default_color
+ )
+ else:
+ family["icon"] = self.default_icon()
+
+ # Update state
+ if name in toggled:
+ state = True
+ else:
+ state = default_state
+ family["state"] = state
+
+ self.family_configs[name] = family
+
+ return self.family_configs
+
+
+class GroupsConfig:
+ # Subset group item's default icon and order
+ _default_group_config = None
+
+ def __init__(self, dbcon):
+ self.dbcon = dbcon
+ self.groups = {}
+
+ @classmethod
+ def default_group_config(cls):
+ if cls._default_group_config is None:
+ cls._default_group_config = {
+ "icon": qtawesome.icon(
+ "fa.object-group",
+ color=style.colors.default
+ ),
+ "order": 0
+ }
+ return cls._default_group_config
+
+ def refresh(self):
+ """Get subset group configurations from the database
+
+ The 'group' configuration must be stored in the project `config` field.
+ See schema `config-1.0.json`
+
+ """
+ # Clear cached groups
+ self.groups.clear()
+
+ group_configs = []
+ project_name = self.dbcon.Session.get("AVALON_PROJECT")
+ if project_name:
+ # Get pre-defined group name and apperance from project config
+ project_doc = self.dbcon.find_one(
+ {"type": "project"},
+ projection={"config.groups": True}
+ )
+
+ if project_doc:
+ group_configs = project_doc["config"].get("groups") or []
+ else:
+ print("Project not found! \"{}\"".format(project_name))
+
+ # Build pre-defined group configs
+ for config in group_configs:
+ name = config["name"]
+ icon = "fa." + config.get("icon", "object-group")
+ color = config.get("color", style.colors.default)
+ order = float(config.get("order", 0))
+
+ self.groups[name] = {
+ "icon": qtawesome.icon(icon, color=color),
+ "order": order
+ }
+
+ return self.groups
+
+ def ordered_groups(self, group_names):
+ # default order zero included
+ _orders = set([0])
+ for config in self.groups.values():
+ _orders.add(config["order"])
+
+ # Remap order to list index
+ orders = sorted(_orders)
+
+ _groups = list()
+ for name in group_names:
+ # Get group config
+ config = self.groups.get(name) or self.default_group_config()
+ # Base order
+ remapped_order = orders.index(config["order"])
+
+ data = {
+ "name": name,
+ "icon": config["icon"],
+ "_order": remapped_order,
+ }
+
+ _groups.append(data)
+
+ # Sort by tuple (base_order, name)
+ # If there are multiple groups in same order, will sorted by name.
+ ordered_groups = sorted(
+ _groups, key=lambda _group: (_group.pop("_order"), _group["name"])
+ )
+
+ total = len(ordered_groups)
+ order_temp = "%0{}d".format(len(str(total)))
+
+ # Update sorted order to config
+ for index, group_data in enumerate(ordered_groups):
+ order = index
+ inverse_order = total - index
+
+ # Format orders into fixed length string for groups sorting
+ group_data["order"] = order_temp % order
+ group_data["inverseOrder"] = order_temp % inverse_order
+
+ return ordered_groups
+
+ def active_groups(self, asset_ids, include_predefined=True):
+ """Collect all active groups from each subset"""
+ # Collect groups from subsets
+ group_names = set(
+ self.dbcon.distinct(
+ "data.subsetGroup",
+ {"type": "subset", "parent": {"$in": asset_ids}}
+ )
+ )
+ if include_predefined:
+ # Ensure all predefined group configs will be included
+ group_names.update(self.groups.keys())
+
+ return self.ordered_groups(group_names)
+
+ def split_subsets_for_groups(self, subset_docs, grouping):
+ """Collect all active groups from each subset"""
+ subset_docs_without_group = collections.defaultdict(list)
+ subset_docs_by_group = collections.defaultdict(dict)
+ for subset_doc in subset_docs:
+ subset_name = subset_doc["name"]
+ if grouping:
+ group_name = subset_doc["data"].get("subsetGroup")
+ if group_name:
+ if subset_name not in subset_docs_by_group[group_name]:
+ subset_docs_by_group[group_name][subset_name] = []
+
+ subset_docs_by_group[group_name][subset_name].append(
+ subset_doc
+ )
+ continue
+
+ subset_docs_without_group[subset_name].append(subset_doc)
+
+ ordered_groups = self.ordered_groups(subset_docs_by_group.keys())
+
+ return ordered_groups, subset_docs_without_group, subset_docs_by_group
+
+
+def create_qthread(func, *args, **kwargs):
+ class Thread(QtCore.QThread):
+ def run(self):
+ func(*args, **kwargs)
+ return Thread()
+
+
+def get_repre_icons():
+ try:
+ from openpype_modules import sync_server
+ except Exception:
+ # Backwards compatibility
+ from openpype.modules import sync_server
+
+ resource_path = os.path.join(
+ os.path.dirname(sync_server.sync_server_module.__file__),
+ "providers", "resources"
+ )
+ icons = {}
+ # TODO get from sync module
+ for provider in ['studio', 'local_drive', 'gdrive']:
+ pix_url = "{}/{}.png".format(resource_path, provider)
+ icons[provider] = QtGui.QIcon(pix_url)
+
+ return icons
+
+
+def get_progress_for_repre(doc, active_site, remote_site):
+ """
+ Calculates average progress for representation.
+
+ If site has created_dt >> fully available >> progress == 1
+
+ Could be calculated in aggregate if it would be too slow
+ Args:
+ doc(dict): representation dict
+ Returns:
+ (dict) with active and remote sites progress
+ {'studio': 1.0, 'gdrive': -1} - gdrive site is not present
+ -1 is used to highlight the site should be added
+ {'studio': 1.0, 'gdrive': 0.0} - gdrive site is present, not
+ uploaded yet
+ """
+ progress = {active_site: -1,
+ remote_site: -1}
+ if not doc:
+ return progress
+
+ files = {active_site: 0, remote_site: 0}
+ doc_files = doc.get("files") or []
+ for doc_file in doc_files:
+ if not isinstance(doc_file, dict):
+ continue
+
+ sites = doc_file.get("sites") or []
+ for site in sites:
+ if (
+ # Pype 2 compatibility
+ not isinstance(site, dict)
+ # Check if site name is one of progress sites
+ or site["name"] not in progress
+ ):
+ continue
+
+ files[site["name"]] += 1
+ norm_progress = max(progress[site["name"]], 0)
+ if site.get("created_dt"):
+ progress[site["name"]] = norm_progress + 1
+ elif site.get("progress"):
+ progress[site["name"]] = norm_progress + site["progress"]
+ else: # site exists, might be failed, do not add again
+ progress[site["name"]] = 0
+
+ # for example 13 fully avail. files out of 26 >> 13/26 = 0.5
+ avg_progress = {}
+ avg_progress[active_site] = \
+ progress[active_site] / max(files[active_site], 1)
+ avg_progress[remote_site] = \
+ progress[remote_site] / max(files[remote_site], 1)
+ return avg_progress
+
+
+def is_sync_loader(loader):
+ return is_remove_site_loader(loader) or is_add_site_loader(loader)
+
+
+def is_remove_site_loader(loader):
+ return hasattr(loader, "remove_site_on_representation")
+
+
+def is_add_site_loader(loader):
+ return hasattr(loader, "add_site_to_representation")
diff --git a/openpype/tools/utils/models.py b/openpype/tools/utils/models.py
new file mode 100644
index 00000000000..c5e1ce1b126
--- /dev/null
+++ b/openpype/tools/utils/models.py
@@ -0,0 +1,500 @@
+import re
+import time
+import logging
+import collections
+
+import Qt
+from Qt import QtCore, QtGui
+from avalon.vendor import qtawesome
+from avalon import style, io
+from . import lib
+
+log = logging.getLogger(__name__)
+
+
+class TreeModel(QtCore.QAbstractItemModel):
+
+ Columns = list()
+ ItemRole = QtCore.Qt.UserRole + 1
+ item_class = None
+
+ def __init__(self, parent=None):
+ super(TreeModel, self).__init__(parent)
+ self._root_item = self.ItemClass()
+
+ @property
+ def ItemClass(self):
+ if self.item_class is not None:
+ return self.item_class
+ return Item
+
+ def rowCount(self, parent=None):
+ if parent is None or not parent.isValid():
+ parent_item = self._root_item
+ else:
+ parent_item = parent.internalPointer()
+ return parent_item.childCount()
+
+ def columnCount(self, parent):
+ return len(self.Columns)
+
+ def data(self, index, role):
+ if not index.isValid():
+ return None
+
+ if role == QtCore.Qt.DisplayRole or role == QtCore.Qt.EditRole:
+ item = index.internalPointer()
+ column = index.column()
+
+ key = self.Columns[column]
+ return item.get(key, None)
+
+ if role == self.ItemRole:
+ return index.internalPointer()
+
+ def setData(self, index, value, role=QtCore.Qt.EditRole):
+ """Change the data on the items.
+
+ Returns:
+ bool: Whether the edit was successful
+ """
+
+ if index.isValid():
+ if role == QtCore.Qt.EditRole:
+
+ item = index.internalPointer()
+ column = index.column()
+ key = self.Columns[column]
+ item[key] = value
+
+ # passing `list()` for PyQt5 (see PYSIDE-462)
+ if Qt.__binding__ in ("PyQt4", "PySide"):
+ self.dataChanged.emit(index, index)
+ else:
+ self.dataChanged.emit(index, index, [role])
+
+ # must return true if successful
+ return True
+
+ return False
+
+ def setColumns(self, keys):
+ assert isinstance(keys, (list, tuple))
+ self.Columns = keys
+
+ def headerData(self, section, orientation, role):
+
+ if role == QtCore.Qt.DisplayRole:
+ if section < len(self.Columns):
+ return self.Columns[section]
+
+ super(TreeModel, self).headerData(section, orientation, role)
+
+ def flags(self, index):
+ flags = QtCore.Qt.ItemIsEnabled
+
+ item = index.internalPointer()
+ if item.get("enabled", True):
+ flags |= QtCore.Qt.ItemIsSelectable
+
+ return flags
+
+ def parent(self, index):
+
+ item = index.internalPointer()
+ parent_item = item.parent()
+
+ # If it has no parents we return invalid
+ if parent_item == self._root_item or not parent_item:
+ return QtCore.QModelIndex()
+
+ return self.createIndex(parent_item.row(), 0, parent_item)
+
+ def index(self, row, column, parent=None):
+ """Return index for row/column under parent"""
+
+ if parent is None or not parent.isValid():
+ parent_item = self._root_item
+ else:
+ parent_item = parent.internalPointer()
+
+ child_item = parent_item.child(row)
+ if child_item:
+ return self.createIndex(row, column, child_item)
+ else:
+ return QtCore.QModelIndex()
+
+ def add_child(self, item, parent=None):
+ if parent is None:
+ parent = self._root_item
+
+ parent.add_child(item)
+
+ def column_name(self, column):
+ """Return column key by index"""
+
+ if column < len(self.Columns):
+ return self.Columns[column]
+
+ def clear(self):
+ self.beginResetModel()
+ self._root_item = self.ItemClass()
+ self.endResetModel()
+
+
+class Item(dict):
+ """An item that can be represented in a tree view using `TreeModel`.
+
+ The item can store data just like a regular dictionary.
+
+ >>> data = {"name": "John", "score": 10}
+ >>> item = Item(data)
+ >>> assert item["name"] == "John"
+
+ """
+
+ def __init__(self, data=None):
+ super(Item, self).__init__()
+
+ self._children = list()
+ self._parent = None
+
+ if data is not None:
+ assert isinstance(data, dict)
+ self.update(data)
+
+ def childCount(self):
+ return len(self._children)
+
+ def child(self, row):
+
+ if row >= len(self._children):
+ log.warning("Invalid row as child: {0}".format(row))
+ return
+
+ return self._children[row]
+
+ def children(self):
+ return self._children
+
+ def parent(self):
+ return self._parent
+
+ def row(self):
+ """
+ Returns:
+ int: Index of this item under parent"""
+ if self._parent is not None:
+ siblings = self.parent().children()
+ return siblings.index(self)
+ return -1
+
+ def add_child(self, child):
+ """Add a child to this item"""
+ child._parent = self
+ self._children.append(child)
+
+
+class AssetModel(TreeModel):
+ """A model listing assets in the silo in the active project.
+
+ The assets are displayed in a treeview, they are visually parented by
+ a `visualParent` field in the database containing an `_id` to a parent
+ asset.
+
+ """
+
+ Columns = ["label"]
+ Name = 0
+ Deprecated = 2
+ ObjectId = 3
+
+ DocumentRole = QtCore.Qt.UserRole + 2
+ ObjectIdRole = QtCore.Qt.UserRole + 3
+ subsetColorsRole = QtCore.Qt.UserRole + 4
+
+ doc_fetched = QtCore.Signal(bool)
+ refreshed = QtCore.Signal(bool)
+
+ # Asset document projection
+ asset_projection = {
+ "type": 1,
+ "schema": 1,
+ "name": 1,
+ "silo": 1,
+ "data.visualParent": 1,
+ "data.label": 1,
+ "data.tags": 1,
+ "data.icon": 1,
+ "data.color": 1,
+ "data.deprecated": 1
+ }
+
+ def __init__(self, dbcon=None, parent=None, asset_projection=None):
+ super(AssetModel, self).__init__(parent=parent)
+ if dbcon is None:
+ dbcon = io
+ self.dbcon = dbcon
+ self.asset_colors = {}
+
+ # Projections for Mongo queries
+ # - let ability to modify them if used in tools that require more than
+ # defaults
+ if asset_projection:
+ self.asset_projection = asset_projection
+
+ self.asset_projection = asset_projection
+
+ self._doc_fetching_thread = None
+ self._doc_fetching_stop = False
+ self._doc_payload = {}
+
+ self.doc_fetched.connect(self.on_doc_fetched)
+
+ self.refresh()
+
+ def _add_hierarchy(self, assets, parent=None, silos=None):
+ """Add the assets that are related to the parent as children items.
+
+ This method does *not* query the database. These instead are queried
+ in a single batch upfront as an optimization to reduce database
+ queries. Resulting in up to 10x speed increase.
+
+ Args:
+ assets (dict): All assets in the currently active silo stored
+ by key/value
+
+ Returns:
+ None
+
+ """
+ # Reset colors
+ self.asset_colors = {}
+
+ if silos:
+ # WARNING: Silo item "_id" is set to silo value
+ # mainly because GUI issue with perserve selection and expanded row
+ # and because of easier hierarchy parenting (in "assets")
+ for silo in silos:
+ item = Item({
+ "_id": silo,
+ "name": silo,
+ "label": silo,
+ "type": "silo"
+ })
+ self.add_child(item, parent=parent)
+ self._add_hierarchy(assets, parent=item)
+
+ parent_id = parent["_id"] if parent else None
+ current_assets = assets.get(parent_id, list())
+
+ for asset in current_assets:
+ # get label from data, otherwise use name
+ data = asset.get("data", {})
+ label = data.get("label", asset["name"])
+ tags = data.get("tags", [])
+
+ # store for the asset for optimization
+ deprecated = "deprecated" in tags
+
+ item = Item({
+ "_id": asset["_id"],
+ "name": asset["name"],
+ "label": label,
+ "type": asset["type"],
+ "tags": ", ".join(tags),
+ "deprecated": deprecated,
+ "_document": asset
+ })
+ self.add_child(item, parent=parent)
+
+ # Add asset's children recursively if it has children
+ if asset["_id"] in assets:
+ self._add_hierarchy(assets, parent=item)
+
+ self.asset_colors[asset["_id"]] = []
+
+ def on_doc_fetched(self, was_stopped):
+ if was_stopped:
+ self.stop_fetch_thread()
+ return
+
+ self.beginResetModel()
+
+ assets_by_parent = self._doc_payload.get("assets_by_parent")
+ silos = self._doc_payload.get("silos")
+ if assets_by_parent is not None:
+ # Build the hierarchical tree items recursively
+ self._add_hierarchy(
+ assets_by_parent,
+ parent=None,
+ silos=silos
+ )
+
+ self.endResetModel()
+
+ has_content = bool(assets_by_parent) or bool(silos)
+ self.refreshed.emit(has_content)
+
+ self.stop_fetch_thread()
+
+ def fetch(self):
+ self._doc_payload = self._fetch() or {}
+ # Emit doc fetched only if was not stopped
+ self.doc_fetched.emit(self._doc_fetching_stop)
+
+ def _fetch(self):
+ if not self.dbcon.Session.get("AVALON_PROJECT"):
+ return
+
+ project_doc = self.dbcon.find_one(
+ {"type": "project"},
+ {"_id": True}
+ )
+ if not project_doc:
+ return
+
+ # Get all assets sorted by name
+ db_assets = self.dbcon.find(
+ {"type": "asset"},
+ self.asset_projection
+ ).sort("name", 1)
+
+ # Group the assets by their visual parent's id
+ assets_by_parent = collections.defaultdict(list)
+ for asset in db_assets:
+ if self._doc_fetching_stop:
+ return
+ parent_id = asset.get("data", {}).get("visualParent")
+ assets_by_parent[parent_id].append(asset)
+
+ return {
+ "assets_by_parent": assets_by_parent,
+ "silos": None
+ }
+
+ def stop_fetch_thread(self):
+ if self._doc_fetching_thread is not None:
+ self._doc_fetching_stop = True
+ while self._doc_fetching_thread.isRunning():
+ time.sleep(0.001)
+ self._doc_fetching_thread = None
+
+ def refresh(self, force=False):
+ """Refresh the data for the model."""
+ # Skip fetch if there is already other thread fetching documents
+ if self._doc_fetching_thread is not None:
+ if not force:
+ return
+ self.stop_fetch_thread()
+
+ # Clear model items
+ self.clear()
+
+ # Fetch documents from mongo
+ # Restart payload
+ self._doc_payload = {}
+ self._doc_fetching_stop = False
+ self._doc_fetching_thread = lib.create_qthread(self.fetch)
+ self._doc_fetching_thread.start()
+
+ def flags(self, index):
+ return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
+
+ def setData(self, index, value, role=QtCore.Qt.EditRole):
+ if not index.isValid():
+ return False
+
+ if role == self.subsetColorsRole:
+ asset_id = index.data(self.ObjectIdRole)
+ self.asset_colors[asset_id] = value
+
+ if Qt.__binding__ in ("PyQt4", "PySide"):
+ self.dataChanged.emit(index, index)
+ else:
+ self.dataChanged.emit(index, index, [role])
+
+ return True
+
+ return super(AssetModel, self).setData(index, value, role)
+
+ def data(self, index, role):
+ if not index.isValid():
+ return
+
+ item = index.internalPointer()
+ if role == QtCore.Qt.DecorationRole:
+ column = index.column()
+ if column == self.Name:
+ # Allow a custom icon and custom icon color to be defined
+ data = item.get("_document", {}).get("data", {})
+ icon = data.get("icon", None)
+ if icon is None and item.get("type") == "silo":
+ icon = "database"
+ color = data.get("color", style.colors.default)
+
+ if icon is None:
+ # Use default icons if no custom one is specified.
+ # If it has children show a full folder, otherwise
+ # show an open folder
+ has_children = self.rowCount(index) > 0
+ icon = "folder" if has_children else "folder-o"
+
+ # Make the color darker when the asset is deprecated
+ if item.get("deprecated", False):
+ color = QtGui.QColor(color).darker(250)
+
+ try:
+ key = "fa.{0}".format(icon) # font-awesome key
+ icon = qtawesome.icon(key, color=color)
+ return icon
+ except Exception as exception:
+ # Log an error message instead of erroring out completely
+ # when the icon couldn't be created (e.g. invalid name)
+ log.error(exception)
+
+ return
+
+ if role == QtCore.Qt.ForegroundRole: # font color
+ if "deprecated" in item.get("tags", []):
+ return QtGui.QColor(style.colors.light).darker(250)
+
+ if role == self.ObjectIdRole:
+ return item.get("_id", None)
+
+ if role == self.DocumentRole:
+ return item.get("_document", None)
+
+ if role == self.subsetColorsRole:
+ asset_id = item.get("_id", None)
+ return self.asset_colors.get(asset_id) or []
+
+ return super(AssetModel, self).data(index, role)
+
+
+class RecursiveSortFilterProxyModel(QtCore.QSortFilterProxyModel):
+ """Filters to the regex if any of the children matches allow parent"""
+ def filterAcceptsRow(self, row, parent):
+ regex = self.filterRegExp()
+ if not regex.isEmpty():
+ pattern = regex.pattern()
+ model = self.sourceModel()
+ source_index = model.index(row, self.filterKeyColumn(), parent)
+ if source_index.isValid():
+ # Check current index itself
+ key = model.data(source_index, self.filterRole())
+ if re.search(pattern, key, re.IGNORECASE):
+ return True
+
+ # Check children
+ rows = model.rowCount(source_index)
+ for i in range(rows):
+ if self.filterAcceptsRow(i, source_index):
+ return True
+
+ # Otherwise filter it
+ return False
+
+ return super(
+ RecursiveSortFilterProxyModel, self
+ ).filterAcceptsRow(row, parent)
diff --git a/openpype/tools/utils/views.py b/openpype/tools/utils/views.py
new file mode 100644
index 00000000000..bed5655647c
--- /dev/null
+++ b/openpype/tools/utils/views.py
@@ -0,0 +1,86 @@
+import os
+from avalon import style
+from Qt import QtWidgets, QtCore, QtGui, QtSvg
+
+
+class DeselectableTreeView(QtWidgets.QTreeView):
+ """A tree view that deselects on clicking on an empty area in the view"""
+
+ def mousePressEvent(self, event):
+
+ index = self.indexAt(event.pos())
+ if not index.isValid():
+ # clear the selection
+ self.clearSelection()
+ # clear the current index
+ self.setCurrentIndex(QtCore.QModelIndex())
+
+ QtWidgets.QTreeView.mousePressEvent(self, event)
+
+
+class TreeViewSpinner(QtWidgets.QTreeView):
+ size = 160
+
+ def __init__(self, parent=None):
+ super(TreeViewSpinner, self).__init__(parent=parent)
+
+ loading_image_path = os.path.join(
+ os.path.dirname(os.path.abspath(style.__file__)),
+ "svg",
+ "spinner-200.svg"
+ )
+ self.spinner = QtSvg.QSvgRenderer(loading_image_path)
+
+ self.is_loading = False
+ self.is_empty = True
+
+ def paint_loading(self, event):
+ rect = event.rect()
+ rect = QtCore.QRectF(rect.topLeft(), rect.bottomRight())
+ rect.moveTo(
+ rect.x() + rect.width() / 2 - self.size / 2,
+ rect.y() + rect.height() / 2 - self.size / 2
+ )
+ rect.setSize(QtCore.QSizeF(self.size, self.size))
+ painter = QtGui.QPainter(self.viewport())
+ self.spinner.render(painter, rect)
+
+ def paint_empty(self, event):
+ painter = QtGui.QPainter(self.viewport())
+ rect = event.rect()
+ rect = QtCore.QRectF(rect.topLeft(), rect.bottomRight())
+ qtext_opt = QtGui.QTextOption(
+ QtCore.Qt.AlignHCenter | QtCore.Qt.AlignVCenter
+ )
+ painter.drawText(rect, "No Data", qtext_opt)
+
+ def paintEvent(self, event):
+ if self.is_loading:
+ self.paint_loading(event)
+ elif self.is_empty:
+ self.paint_empty(event)
+ else:
+ super(TreeViewSpinner, self).paintEvent(event)
+
+
+class AssetsView(TreeViewSpinner, DeselectableTreeView):
+ """Item view.
+ This implements a context menu.
+ """
+
+ def __init__(self):
+ super(AssetsView, self).__init__()
+ self.setIndentation(15)
+ self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
+ self.setHeaderHidden(True)
+
+ def mousePressEvent(self, event):
+ index = self.indexAt(event.pos())
+ if not index.isValid():
+ modifiers = QtWidgets.QApplication.keyboardModifiers()
+ if modifiers == QtCore.Qt.ShiftModifier:
+ return
+ elif modifiers == QtCore.Qt.ControlModifier:
+ return
+
+ super(AssetsView, self).mousePressEvent(event)
diff --git a/openpype/tools/utils/widgets.py b/openpype/tools/utils/widgets.py
new file mode 100644
index 00000000000..b9b542c1237
--- /dev/null
+++ b/openpype/tools/utils/widgets.py
@@ -0,0 +1,499 @@
+import logging
+import time
+
+from . import lib
+
+from Qt import QtWidgets, QtCore, QtGui
+from avalon.vendor import qtawesome, qargparse
+
+from avalon import style
+
+from .models import AssetModel, RecursiveSortFilterProxyModel
+from .views import AssetsView
+from .delegates import AssetDelegate
+
+log = logging.getLogger(__name__)
+
+
+class AssetWidget(QtWidgets.QWidget):
+ """A Widget to display a tree of assets with filter
+
+ To list the assets of the active project:
+ >>> # widget = AssetWidget()
+ >>> # widget.refresh()
+ >>> # widget.show()
+
+ """
+
+ refresh_triggered = QtCore.Signal() # on model refresh
+ refreshed = QtCore.Signal()
+ selection_changed = QtCore.Signal() # on view selection change
+ current_changed = QtCore.Signal() # on view current index change
+
+ def __init__(self, dbcon, multiselection=False, parent=None):
+ super(AssetWidget, self).__init__(parent=parent)
+
+ self.dbcon = dbcon
+
+ self.setContentsMargins(0, 0, 0, 0)
+
+ layout = QtWidgets.QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(4)
+
+ # Tree View
+ model = AssetModel(dbcon=self.dbcon, parent=self)
+ proxy = RecursiveSortFilterProxyModel()
+ proxy.setSourceModel(model)
+ proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive)
+
+ view = AssetsView()
+ view.setModel(proxy)
+ if multiselection:
+ asset_delegate = AssetDelegate()
+ view.setSelectionMode(view.ExtendedSelection)
+ view.setItemDelegate(asset_delegate)
+
+ # Header
+ header = QtWidgets.QHBoxLayout()
+
+ icon = qtawesome.icon("fa.arrow-down", color=style.colors.light)
+ set_current_asset_btn = QtWidgets.QPushButton(icon, "")
+ set_current_asset_btn.setToolTip("Go to Asset from current Session")
+ # Hide by default
+ set_current_asset_btn.setVisible(False)
+
+ icon = qtawesome.icon("fa.refresh", color=style.colors.light)
+ refresh = QtWidgets.QPushButton(icon, "")
+ refresh.setToolTip("Refresh items")
+
+ filter = QtWidgets.QLineEdit()
+ filter.textChanged.connect(proxy.setFilterFixedString)
+ filter.setPlaceholderText("Filter assets..")
+
+ header.addWidget(filter)
+ header.addWidget(set_current_asset_btn)
+ header.addWidget(refresh)
+
+ # Layout
+ layout.addLayout(header)
+ layout.addWidget(view)
+
+ # Signals/Slots
+ selection = view.selectionModel()
+ selection.selectionChanged.connect(self.selection_changed)
+ selection.currentChanged.connect(self.current_changed)
+ refresh.clicked.connect(self.refresh)
+ set_current_asset_btn.clicked.connect(self.set_current_session_asset)
+
+ self.set_current_asset_btn = set_current_asset_btn
+ self.model = model
+ self.proxy = proxy
+ self.view = view
+
+ self.model_selection = {}
+
+ def set_current_asset_btn_visibility(self, visible=None):
+ """Hide set current asset button.
+
+ Not all tools support using of current context asset.
+ """
+ if visible is None:
+ visible = not self.set_current_asset_btn.isVisible()
+ self.set_current_asset_btn.setVisible(visible)
+
+ def _refresh_model(self):
+ # Store selection
+ self._store_model_selection()
+ time_start = time.time()
+
+ self.set_loading_state(
+ loading=True,
+ empty=True
+ )
+
+ def on_refreshed(has_item):
+ self.set_loading_state(loading=False, empty=not has_item)
+ self._restore_model_selection()
+ self.model.refreshed.disconnect()
+ self.refreshed.emit()
+ print("Duration: %.3fs" % (time.time() - time_start))
+
+ # Connect to signal
+ self.model.refreshed.connect(on_refreshed)
+ # Trigger signal before refresh is called
+ self.refresh_triggered.emit()
+ # Refresh model
+ self.model.refresh()
+
+ def refresh(self):
+ self._refresh_model()
+
+ def get_active_asset(self):
+ """Return the asset item of the current selection."""
+ current = self.view.currentIndex()
+ return current.data(self.model.ItemRole)
+
+ def get_active_asset_document(self):
+ """Return the asset document of the current selection."""
+ current = self.view.currentIndex()
+ return current.data(self.model.DocumentRole)
+
+ def get_active_index(self):
+ return self.view.currentIndex()
+
+ def get_selected_assets(self):
+ """Return the documents of selected assets."""
+ selection = self.view.selectionModel()
+ rows = selection.selectedRows()
+ assets = [row.data(self.model.DocumentRole) for row in rows]
+
+ # NOTE: skip None object assumed they are silo (backwards comp.)
+ return [asset for asset in assets if asset]
+
+ def select_assets(self, assets, expand=True, key="name"):
+ """Select assets by item key.
+
+ Args:
+ assets (list): List of asset values that can be found under
+ specified `key`
+ expand (bool): Whether to also expand to the asset in the view
+ key (string): Key that specifies where to look for `assets` values
+
+ Returns:
+ None
+
+ Default `key` is "name" in that case `assets` should contain single
+ asset name or list of asset names. (It is good idea to use "_id" key
+ instead of name in that case `assets` must contain `ObjectId` object/s)
+ It is expected that each value in `assets` will be found only once.
+ If the filters according to the `key` and `assets` correspond to
+ the more asset, only the first found will be selected.
+
+ """
+
+ if not isinstance(assets, (tuple, list)):
+ assets = [assets]
+
+ # convert to list - tuple cant be modified
+ assets = set(assets)
+
+ # Clear selection
+ selection_model = self.view.selectionModel()
+ selection_model.clearSelection()
+
+ # Select
+ mode = selection_model.Select | selection_model.Rows
+ for index in lib.iter_model_rows(
+ self.proxy, column=0, include_root=False
+ ):
+ # stop iteration if there are no assets to process
+ if not assets:
+ break
+
+ value = index.data(self.model.ItemRole).get(key)
+ if value not in assets:
+ continue
+
+ # Remove processed asset
+ assets.discard(value)
+
+ selection_model.select(index, mode)
+ if expand:
+ # Expand parent index
+ self.view.expand(self.proxy.parent(index))
+
+ # Set the currently active index
+ self.view.setCurrentIndex(index)
+
+ def set_loading_state(self, loading, empty):
+ if self.view.is_loading != loading:
+ if loading:
+ self.view.spinner.repaintNeeded.connect(
+ self.view.viewport().update
+ )
+ else:
+ self.view.spinner.repaintNeeded.disconnect()
+
+ self.view.is_loading = loading
+ self.view.is_empty = empty
+
+ def _store_model_selection(self):
+ index = self.view.currentIndex()
+ current = None
+ if index and index.isValid():
+ current = index.data(self.model.ObjectIdRole)
+
+ expanded = set()
+ model = self.view.model()
+ for index in lib.iter_model_rows(
+ model, column=0, include_root=False
+ ):
+ if self.view.isExpanded(index):
+ value = index.data(self.model.ObjectIdRole)
+ expanded.add(value)
+
+ selection_model = self.view.selectionModel()
+
+ selected = None
+ selected_rows = selection_model.selectedRows()
+ if selected_rows:
+ selected = set(
+ row.data(self.model.ObjectIdRole)
+ for row in selected_rows
+ )
+
+ self.model_selection = {
+ "expanded": expanded,
+ "selected": selected,
+ "current": current
+ }
+
+ def _restore_model_selection(self):
+ model = self.view.model()
+ not_set = object()
+ expanded = self.model_selection.pop("expanded", not_set)
+ selected = self.model_selection.pop("selected", not_set)
+ current = self.model_selection.pop("current", not_set)
+
+ if (
+ expanded is not_set
+ or selected is not_set
+ or current is not_set
+ ):
+ return
+
+ if expanded:
+ for index in lib.iter_model_rows(
+ model, column=0, include_root=False
+ ):
+ is_expanded = index.data(self.model.ObjectIdRole) in expanded
+ self.view.setExpanded(index, is_expanded)
+
+ if not selected and not current:
+ self.set_current_session_asset()
+ return
+
+ current_index = None
+ selected_indexes = []
+ # Go through all indices, select the ones with similar data
+ for index in lib.iter_model_rows(
+ model, column=0, include_root=False
+ ):
+ object_id = index.data(self.model.ObjectIdRole)
+ if object_id in selected:
+ selected_indexes.append(index)
+
+ if not current_index and object_id == current:
+ current_index = index
+
+ if current_index:
+ self.view.setCurrentIndex(current_index)
+
+ if not selected_indexes:
+ return
+ selection_model = self.view.selectionModel()
+ flags = selection_model.Select | selection_model.Rows
+ for index in selected_indexes:
+ # Ensure item is visible
+ self.view.scrollTo(index)
+ selection_model.select(index, flags)
+
+ def set_current_session_asset(self):
+ asset_name = self.dbcon.Session.get("AVALON_ASSET")
+ if asset_name:
+ self.select_assets([asset_name])
+
+
+class OptionalMenu(QtWidgets.QMenu):
+ """A subclass of `QtWidgets.QMenu` to work with `OptionalAction`
+
+ This menu has reimplemented `mouseReleaseEvent`, `mouseMoveEvent` and
+ `leaveEvent` to provide better action hightlighting and triggering for
+ actions that were instances of `QtWidgets.QWidgetAction`.
+
+ """
+
+ def mouseReleaseEvent(self, event):
+ """Emit option clicked signal if mouse released on it"""
+ active = self.actionAt(event.pos())
+ if active and active.use_option:
+ option = active.widget.option
+ if option.is_hovered(event.globalPos()):
+ option.clicked.emit()
+ super(OptionalMenu, self).mouseReleaseEvent(event)
+
+ def mouseMoveEvent(self, event):
+ """Add highlight to active action"""
+ active = self.actionAt(event.pos())
+ for action in self.actions():
+ action.set_highlight(action is active, event.globalPos())
+ super(OptionalMenu, self).mouseMoveEvent(event)
+
+ def leaveEvent(self, event):
+ """Remove highlight from all actions"""
+ for action in self.actions():
+ action.set_highlight(False)
+ super(OptionalMenu, self).leaveEvent(event)
+
+
+class OptionalAction(QtWidgets.QWidgetAction):
+ """Menu action with option box
+
+ A menu action like Maya's menu item with option box, implemented by
+ subclassing `QtWidgets.QWidgetAction`.
+
+ """
+
+ def __init__(self, label, icon, use_option, parent):
+ super(OptionalAction, self).__init__(parent)
+ self.label = label
+ self.icon = icon
+ self.use_option = use_option
+ self.option_tip = ""
+ self.optioned = False
+
+ def createWidget(self, parent):
+ widget = OptionalActionWidget(self.label, parent)
+ self.widget = widget
+
+ if self.icon:
+ widget.setIcon(self.icon)
+
+ if self.use_option:
+ widget.option.clicked.connect(self.on_option)
+ widget.option.setToolTip(self.option_tip)
+ else:
+ widget.option.setVisible(False)
+
+ return widget
+
+ def set_option_tip(self, options):
+ sep = "\n\n"
+ mak = (lambda opt: opt["name"] + " :\n " + opt["help"])
+ self.option_tip = sep.join(mak(opt) for opt in options)
+
+ def on_option(self):
+ self.optioned = True
+
+ def set_highlight(self, state, global_pos=None):
+ body = self.widget.body
+ option = self.widget.option
+
+ role = QtGui.QPalette.Highlight if state else QtGui.QPalette.Window
+ body.setBackgroundRole(role)
+ body.setAutoFillBackground(state)
+
+ if not self.use_option:
+ return
+
+ state = option.is_hovered(global_pos)
+ role = QtGui.QPalette.Highlight if state else QtGui.QPalette.Window
+ option.setBackgroundRole(role)
+ option.setAutoFillBackground(state)
+
+
+class OptionalActionWidget(QtWidgets.QWidget):
+ """Main widget class for `OptionalAction`"""
+
+ def __init__(self, label, parent=None):
+ super(OptionalActionWidget, self).__init__(parent)
+
+ body = QtWidgets.QWidget()
+ body.setStyleSheet("background: transparent;")
+
+ icon = QtWidgets.QLabel()
+ label = QtWidgets.QLabel(label)
+ option = OptionBox(body)
+
+ icon.setFixedSize(24, 16)
+ option.setFixedSize(30, 30)
+
+ layout = QtWidgets.QHBoxLayout(body)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(2)
+ layout.addWidget(icon)
+ layout.addWidget(label)
+ layout.addSpacing(6)
+
+ layout = QtWidgets.QHBoxLayout(self)
+ layout.setContentsMargins(6, 1, 2, 1)
+ layout.setSpacing(0)
+ layout.addWidget(body)
+ layout.addWidget(option)
+
+ body.setMouseTracking(True)
+ label.setMouseTracking(True)
+ option.setMouseTracking(True)
+ self.setMouseTracking(True)
+ self.setFixedHeight(32)
+
+ self.icon = icon
+ self.label = label
+ self.option = option
+ self.body = body
+
+ # (NOTE) For removing ugly QLable shadow FX when highlighted in Nuke.
+ # See https://stackoverflow.com/q/52838690/4145300
+ label.setStyle(QtWidgets.QStyleFactory.create("Plastique"))
+
+ def setIcon(self, icon):
+ pixmap = icon.pixmap(16, 16)
+ self.icon.setPixmap(pixmap)
+
+
+class OptionBox(QtWidgets.QLabel):
+ """Option box widget class for `OptionalActionWidget`"""
+
+ clicked = QtCore.Signal()
+
+ def __init__(self, parent):
+ super(OptionBox, self).__init__(parent)
+
+ self.setAlignment(QtCore.Qt.AlignCenter)
+
+ icon = qtawesome.icon("fa.sticky-note-o", color="#c6c6c6")
+ pixmap = icon.pixmap(18, 18)
+ self.setPixmap(pixmap)
+
+ self.setStyleSheet("background: transparent;")
+
+ def is_hovered(self, global_pos):
+ if global_pos is None:
+ return False
+ pos = self.mapFromGlobal(global_pos)
+ return self.rect().contains(pos)
+
+
+class OptionDialog(QtWidgets.QDialog):
+ """Option dialog shown by option box"""
+
+ def __init__(self, parent=None):
+ super(OptionDialog, self).__init__(parent)
+ self.setModal(True)
+ self._options = dict()
+
+ def create(self, options):
+ parser = qargparse.QArgumentParser(arguments=options)
+
+ decision = QtWidgets.QWidget()
+ accept = QtWidgets.QPushButton("Accept")
+ cancel = QtWidgets.QPushButton("Cancel")
+
+ layout = QtWidgets.QHBoxLayout(decision)
+ layout.addWidget(accept)
+ layout.addWidget(cancel)
+
+ layout = QtWidgets.QVBoxLayout(self)
+ layout.addWidget(parser)
+ layout.addWidget(decision)
+
+ accept.clicked.connect(self.accept)
+ cancel.clicked.connect(self.reject)
+ parser.changed.connect(self.on_changed)
+
+ def on_changed(self, argument):
+ self._options[argument["name"]] = argument.read()
+
+ def parse(self):
+ return self._options.copy()