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()