From 11f0c5b397d00fc7b9ffd4bbabd37c0f0d5d8c9b Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 22 Sep 2023 12:03:12 +0200 Subject: [PATCH 01/69] initial commitof ayon loader --- openpype/tools/ayon_loader/__init__.py | 0 openpype/tools/ayon_loader/abstract.py | 151 ++++++++ openpype/tools/ayon_loader/control.py | 83 ++++ openpype/tools/ayon_loader/models/__init__.py | 8 + openpype/tools/ayon_loader/models/products.py | 266 +++++++++++++ .../tools/ayon_loader/models/selection.py | 67 ++++ openpype/tools/ayon_loader/ui/__init__.py | 6 + .../tools/ayon_loader/ui/folders_widget.py | 360 ++++++++++++++++++ .../ayon_loader/ui/products_delegates.py | 163 ++++++++ .../tools/ayon_loader/ui/products_model.py | 353 +++++++++++++++++ .../tools/ayon_loader/ui/products_widget.py | 105 +++++ openpype/tools/ayon_loader/ui/window.py | 119 ++++++ 12 files changed, 1681 insertions(+) create mode 100644 openpype/tools/ayon_loader/__init__.py create mode 100644 openpype/tools/ayon_loader/abstract.py create mode 100644 openpype/tools/ayon_loader/control.py create mode 100644 openpype/tools/ayon_loader/models/__init__.py create mode 100644 openpype/tools/ayon_loader/models/products.py create mode 100644 openpype/tools/ayon_loader/models/selection.py create mode 100644 openpype/tools/ayon_loader/ui/__init__.py create mode 100644 openpype/tools/ayon_loader/ui/folders_widget.py create mode 100644 openpype/tools/ayon_loader/ui/products_delegates.py create mode 100644 openpype/tools/ayon_loader/ui/products_model.py create mode 100644 openpype/tools/ayon_loader/ui/products_widget.py create mode 100644 openpype/tools/ayon_loader/ui/window.py diff --git a/openpype/tools/ayon_loader/__init__.py b/openpype/tools/ayon_loader/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/openpype/tools/ayon_loader/abstract.py b/openpype/tools/ayon_loader/abstract.py new file mode 100644 index 00000000000..7b8f0b696cc --- /dev/null +++ b/openpype/tools/ayon_loader/abstract.py @@ -0,0 +1,151 @@ +from abc import ABCMeta, abstractmethod +import six + + +class VersionItem: + def __init__( + self, + version_id, + version, + is_hero, + subset_id, + thumbnail_id, + published_time, + author, + frame_range, + duration, + handles, + step, + in_scene + ): + self.version_id = version_id + self.subset_id = subset_id + self.thumbnail_id = thumbnail_id + self.version = version + self.is_hero = is_hero + self.published_time = published_time + self.author = author + self.frame_range = frame_range + self.duration = duration + self.handles = handles + self.step = step + self.in_scene = in_scene + + def __eq__(self, other): + if not isinstance(other, VersionItem): + return False + return ( + self.is_hero == other.is_hero + and self.version == other.version + and self.version_id == other.version_id + and self.subset_id == other.subset_id + ) + + def __ne__(self, other): + return not self.__eq__(other) + + def __gt__(self, other): + if not isinstance(other, VersionItem): + return False + if ( + other.version == self.version + and self.is_hero + ): + return True + return other.version < self.version + + def to_data(self): + return { + "version_id": self.version_id, + "subset_id": self.subset_id, + "thumbnail_id": self.thumbnail_id, + "version": self.version, + "is_hero": self.is_hero, + "published_time": self.published_time, + "author": self.author, + "frame_range": self.frame_range, + "duration": self.duration, + "handles": self.handles, + "step": self.step, + "in_scene": self.in_scene, + } + + @classmethod + def from_data(cls, data): + return cls(**data) + + +class ProductItem: + def __init__( + self, + product_id, + product_type, + product_name, + folder_id, + folder_label, + group_name, + version_items + ): + self.product_id = product_id + self.product_type = product_type + self.product_name = product_name + self.folder_id = folder_id + self.folder_label = folder_label + self.group_name = group_name + self.version_items = version_items + + def to_data(self): + return { + "product_id": self.product_id, + "product_type": self.product_type, + "product_name": self.product_name, + "folder_id": self.folder_id, + "folder_label": self.folder_label, + "group_name": self.group_name, + "version_items": [ + version_item.to_data() + for version_item in self.version_items + ], + } + + @classmethod + def from_data(cls, data): + version_items = [ + VersionItem.from_data(version) + for version in data["version_items"] + ] + data["version_items"] = version_items + return cls(**data) + + +@six.add_metaclass(ABCMeta) +class AbstractController: + @abstractmethod + def get_current_project(self): + pass + + @abstractmethod + def reset(self): + pass + + # Model wrapper calls + @abstractmethod + def get_project_items(self): + pass + + # Selection model wrapper calls + @abstractmethod + def get_selected_project_name(self): + pass + + @abstractmethod + def set_selected_project(self, project_name): + pass + + @abstractmethod + def get_selected_folder_ids(self): + pass + + @abstractmethod + def set_selected_folders(self, folder_ids): + pass diff --git a/openpype/tools/ayon_loader/control.py b/openpype/tools/ayon_loader/control.py new file mode 100644 index 00000000000..1ca19fcf77b --- /dev/null +++ b/openpype/tools/ayon_loader/control.py @@ -0,0 +1,83 @@ +import logging + +from openpype.lib.events import QueuedEventSystem + +from openpype.tools.ayon_utils.models import ProjectsModel, HierarchyModel + +from .abstract import AbstractController +from .models import SelectionModel, ProductsModel + + +class LoaderController(AbstractController): + def __init__(self): + self._log = None + self._event_system = self._create_event_system() + + self._selection_model = SelectionModel(self) + self._projects_model = ProjectsModel(self) + self._hierarchy_model = HierarchyModel(self) + self._products_model = ProductsModel(self) + + @property + def log(self): + if self._log is None: + self._log = logging.getLogger(self.__class__.__name__) + return self._log + + # --------------------------------- + # Implementation of abstract methods + # --------------------------------- + # Events system + def emit_event(self, topic, data=None, source=None): + """Use implemented event system to trigger event.""" + + if data is None: + data = {} + self._event_system.emit(topic, data, source) + + def register_event_callback(self, topic, callback): + self._event_system.add_callback(topic, callback) + + def reset(self): + self._emit_event("controller.reset.started") + self._products_model.reset() + self._hierarchy_model.reset() + self._projects_model.refresh() + self._emit_event("controller.reset.finished") + + def get_current_project(self): + return None + + # Entity model wrappers + def get_project_items(self, sender=None): + return self._projects_model.get_project_items(sender) + + def get_folder_items(self, project_name, sender=None): + return self._hierarchy_model.get_folder_items(project_name, sender) + + def get_product_items(self, project_name, folder_ids, sender=None): + return self._products_model.get_product_items( + project_name, folder_ids, sender) + + def get_folder_entity(self, project_name, folder_id): + self._hierarchy_model.get_folder_entity(project_name, folder_id) + + # Selection model wrappers + def get_selected_project_name(self): + return self._selection_model.get_selected_project_name() + + def set_selected_project(self, project_name): + self._selection_model.set_selected_project(project_name) + + # Selection model wrappers + def get_selected_folder_ids(self): + self._selection_model.get_selected_folder_ids() + + def set_selected_folders(self, folder_ids): + self._selection_model.set_selected_folders(folder_ids) + + def _create_event_system(self): + return QueuedEventSystem() + + def _emit_event(self, topic, data=None): + self._event_system.emit(topic, data or {}, "controller") diff --git a/openpype/tools/ayon_loader/models/__init__.py b/openpype/tools/ayon_loader/models/__init__.py new file mode 100644 index 00000000000..b7ab3865651 --- /dev/null +++ b/openpype/tools/ayon_loader/models/__init__.py @@ -0,0 +1,8 @@ +from .selection import SelectionModel +from .products import ProductsModel + + +__all__ = ( + "SelectionModel", + "ProductsModel", +) diff --git a/openpype/tools/ayon_loader/models/products.py b/openpype/tools/ayon_loader/models/products.py new file mode 100644 index 00000000000..f39be1d6379 --- /dev/null +++ b/openpype/tools/ayon_loader/models/products.py @@ -0,0 +1,266 @@ +import collections +import contextlib + +import arrow +import ayon_api + +from openpype.tools.ayon_utils.models import NestedCacheItem +from openpype.tools.ayon_loader.abstract import VersionItem, ProductItem + +PRODUCTS_MODEL_SENDER = "products.model" + + +def version_item_from_entity(version): + version_attribs = version["attrib"] + frame_start = version_attribs.get("frameStart") + frame_end = version_attribs.get("frameEnd") + handle_start = version_attribs.get("handleStart") + handle_end = version_attribs.get("handleEnd") + step = version_attribs.get("step") + + frame_range = None + duration = None + 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 + frame_start = int(frame_start) + frame_end = int(frame_end) + frame_range = "{}-{}".format(frame_start, frame_end) + duration = frame_end - frame_start + 1 + + if handle_start is not None and handle_end is not None: + handles = "{}-{}".format(int(handle_start), int(handle_end)) + + # NOTE There is also 'updatedAt', should be used that instead? + # TODO skip conversion - converting to '%Y%m%dT%H%M%SZ' is because + # 'PrettyTimeDelegate' expects it + created_at = arrow.get(version["createdAt"]) + published_time = created_at.strftime("%Y%m%dT%H%M%SZ") + author = version["author"] + version_num = version["version"] + is_hero = version_num < 0 + + return VersionItem( + version_id=version["id"], + version=version_num, + is_hero=is_hero, + subset_id=version["productId"], + thumbnail_id=version["thumbnailId"], + published_time=published_time, + author=author, + frame_range=frame_range, + duration=duration, + handles=handles, + step=step, + in_scene=None, + ) + + +def product_item_from_entity(product_entity, version_entities, folder_label): + product_attribs = product_entity["attrib"] + group = product_attribs.get("productGroup") + + version_items = [ + version_item_from_entity(version_entity) + for version_entity in version_entities + ] + + return ProductItem( + product_id=product_entity["id"], + product_type=product_entity["productType"], + product_name=product_entity["name"], + folder_id=product_entity["folderId"], + folder_label=folder_label, + group_name=group, + version_items=version_items, + ) + + +class RepreItem: + def __init__(self, repre_id, version_id): + self.repre_id = repre_id + self.version_id = version_id + + @classmethod + def from_doc(cls, repre_doc): + return cls( + str(repre_doc["_id"]), + str(repre_doc["parent"]), + ) + + +class ProductsModel: + def __init__(self, controller): + self._controller = controller + + self._product_items_cache = NestedCacheItem( + levels=2, default_factory=dict) + self._repre_items_cache = NestedCacheItem( + levels=3, default_factory=dict) + + def reset(self): + self._product_items_cache.reset() + self._repre_items_cache.reset() + + def get_product_items(self, project_name, folder_ids, sender): + if not project_name or not folder_ids: + return [] + + project_cache = self._product_items_cache[project_name] + caches = [] + folder_ids_to_update = set() + for folder_id in folder_ids: + cache = project_cache[folder_id] + caches.append(cache) + if not cache.is_valid: + folder_ids_to_update.add(folder_id) + + self._refresh_product_items( + project_name, folder_ids_to_update, sender) + + output = [] + for cache in caches: + output.extend(cache.get_data().values()) + return output + + def get_repre_items(self, project_name, version_ids): + output = {} + if not version_ids: + return output + repre_ids_cache = self._repre_items_cache.get(project_name) + if repre_ids_cache is None: + return output + + for version_id in version_ids: + data = repre_ids_cache[version_id].get_data() + if data: + output.update(data) + return output + + def _refresh_product_items(self, project_name, folder_ids, sender): + if not project_name or not folder_ids: + return + + with self._product_refresh_event_manager( + project_name, folder_ids, sender + ): + folder_items = self._controller.get_folder_items(project_name) + items_by_folder_id = { + folder_id: {} + for folder_id in folder_ids + } + products = list(ayon_api.get_products( + project_name, folder_ids=folder_ids + )) + product_ids = {product["id"] for product in products} + versions = ayon_api.get_versions( + project_name, product_ids=product_ids) + + versions_by_product_id = collections.defaultdict(list) + for version in versions: + versions_by_product_id[version["productId"]].append(version) + + for product in products: + product_id = product["id"] + folder_id = product["folderId"] + folder_item = folder_items.get(folder_id) + if not folder_item: + continue + versions = versions_by_product_id[product_id] + if not versions: + continue + product_item = product_item_from_entity( + product, versions, folder_item.label + ) + items_by_folder_id[product_item.folder_id][product_id] = ( + product_item + ) + + project_cache = self._product_items_cache[project_name] + for folder_id, product_items in items_by_folder_id.items(): + project_cache[folder_id].update_data(product_items) + + @contextlib.contextmanager + def _product_refresh_event_manager( + self, project_name, folder_ids, sender + ): + self._controller.emit_event( + "products.refresh.started", + { + "project_name": project_name, + "sender": sender, + "folder_ids": folder_ids, + }, + PRODUCTS_MODEL_SENDER + ) + try: + yield + + finally: + self._controller.emit_event( + "products.refresh.finished", + { + "project_name": project_name, + "sender": sender, + "folder_ids": folder_ids, + }, + PRODUCTS_MODEL_SENDER + ) + + def refresh_representations(self, project_name, version_ids): + self._controller.event_system.emit( + "model.representations.refresh.started", + { + "project_name": project_name, + "version_ids": version_ids, + }, + "products.model" + ) + failed = False + try: + self._refresh_representations(project_name, version_ids) + except Exception: + failed = True + + self._controller.event_system.emit( + "model.representations.refresh.finished", + { + "project_name": project_name, + "version_ids": version_ids, + "failed": failed, + }, + "products.model" + ) + + def _refresh_representations(self, project_name, version_ids): + pass + # if project_name not in self._repre_items_cache: + # self._repre_items_cache[project_name] = ( + # collections.defaultdict(CacheItem.create_outdated) + # ) + # + # version_ids_to_query = set() + # repre_cache = self._repre_items_cache[project_name] + # for version_id in version_ids: + # if repre_cache[version_id].is_outdated: + # version_ids_to_query.add(version_id) + # + # if not version_ids_to_query: + # return + # + # repre_entities_by_version_id = { + # version_id: {} + # for version_id in version_ids_to_query + # } + # repre_entities = ayon_api.get_representations( + # project_name, version_ids=version_ids_to_query + # ) + # for repre_entity in repre_entities: + # repre_item = RepreItem.from_doc(repre_entity) + # repre_entities_by_version_id[repre_item.version_id][repre_item.id] = { + # repre_item + # } + # + # for version_id, repre_items in repre_docs_by_version_id.items(): + # repre_cache[version_id].update_data(repre_items) diff --git a/openpype/tools/ayon_loader/models/selection.py b/openpype/tools/ayon_loader/models/selection.py new file mode 100644 index 00000000000..834144fba5f --- /dev/null +++ b/openpype/tools/ayon_loader/models/selection.py @@ -0,0 +1,67 @@ +class SelectionModel(object): + """Model handling selection changes. + + Triggering events: + - "selection.project.changed" + - "selection.folders.changed" + - "selection.versions.changed" + """ + + event_source = "selection.model" + + def __init__(self, controller): + self._controller = controller + + self._project_name = None + self._folder_ids = set() + self._version_ids = set() + + def get_selected_project_name(self): + return self._project_name + + def set_selected_project(self, project_name): + if self._project_name == project_name: + return + + self._project_name = project_name + self._controller.emit_event( + "selection.project.changed", + {"project_name": self._project_name}, + self.event_source + ) + + def get_selected_folder_ids(self): + return self._folder_ids + + def set_selected_folders(self, folder_ids): + if folder_ids == self._folder_ids: + return + + self._folder_ids = folder_ids + self._controller.emit_event( + "selection.folders.changed", + { + "project_name": self._project_name, + "folder_ids": folder_ids, + }, + self.event_source + ) + + def get_selected_version_ids(self): + return self._version_ids + + def set_selected_version_ids(self, version_ids): + if version_ids == self._version_ids: + return + + + self._version_ids = version_ids + self._controller.emit_event( + "selection.versions.changed", + { + "project_name": self._project_name, + "folder_ids": self._folder_ids, + "version_ids": self._version_ids, + }, + self.event_source + ) diff --git a/openpype/tools/ayon_loader/ui/__init__.py b/openpype/tools/ayon_loader/ui/__init__.py new file mode 100644 index 00000000000..41e44186418 --- /dev/null +++ b/openpype/tools/ayon_loader/ui/__init__.py @@ -0,0 +1,6 @@ +from .window import LoaderWindow + + +__all__ = ( + "LoaderWindow", +) diff --git a/openpype/tools/ayon_loader/ui/folders_widget.py b/openpype/tools/ayon_loader/ui/folders_widget.py new file mode 100644 index 00000000000..2ce54b93184 --- /dev/null +++ b/openpype/tools/ayon_loader/ui/folders_widget.py @@ -0,0 +1,360 @@ +import qtpy +from qtpy import QtWidgets, QtCore, QtGui + +from openpype.tools.utils import ( + RecursiveSortFilterProxyModel, + DeselectableTreeView, +) +from openpype.style import ( + get_objected_colors, + get_default_tools_icon_color, +) + +from openpype.tools.ayon_utils.widgets import ( + FoldersModel, + FOLDERS_MODEL_SENDER_NAME, +) +from openpype.tools.ayon_utils.widgets.folders_widget import ITEM_ID_ROLE + +if qtpy.API == "pyside": + from PySide.QtGui import QStyleOptionViewItemV4 +elif qtpy.API == "pyqt4": + from PyQt4.QtGui import QStyleOptionViewItemV4 + +UNDERLINE_COLORS_ROLE = QtCore.Qt.UserRole + 4 + + +class UnderlinesAssetDelegate(QtWidgets.QItemDelegate): + """Item delegate drawing bars under asset name. + + This is used in loader and library loader tools. Multiselection of assets + may group subsets by name under colored groups. Selected color groups are + then propagated back to selected assets as underlines. + """ + bar_height = 3 + + def __init__(self, *args, **kwargs): + super(UnderlinesAssetDelegate, self).__init__(*args, **kwargs) + colors = get_objected_colors("loader", "asset-view") + self._selected_color = colors["selected"].get_qcolor() + self._hover_color = colors["hover"].get_qcolor() + self._selected_hover_color = colors["selected-hover"].get_qcolor() + + def sizeHint(self, option, index): + """Add bar height to size hint.""" + result = super(UnderlinesAssetDelegate, self).sizeHint(option, index) + height = result.height() + result.setHeight(height + self.bar_height) + + return result + + def paint(self, painter, option, index): + """Replicate painting of an item and draw color bars if needed.""" + # Qt4 compat + if qtpy.API 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(UNDERLINE_COLORS_ROLE) or [] + 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 + 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 = self._selected_hover_color + else: + bg_color = self._selected_color + else: + item_rect.setTop(item_rect.top() + (self.bar_height / 2)) + if option.state & QtWidgets.QStyle.State_MouseOver: + bg_color = self._hover_color + else: + bg_color = QtGui.QColor() + bg_color.setAlpha(0) + + # When not needed to do a rounded corners (easier and without + # painter restore): + painter.fillRect( + option.rect, + QtGui.QBrush(bg_color) + ) + + 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)) + + # 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 + actual_size = option.icon.actualSize( + option.decorationSize, mode, state + ) + option.decorationSize = QtCore.QSize( + min(option.decorationSize.width(), actual_size.width()), + min(option.decorationSize.height(), actual_size.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 LoaderFoldersModel(FoldersModel): + def _fill_item_data(self, item, folder_item): + """ + + Args: + item (QtGui.QStandardItem): Item to fill data. + folder_item (FolderItem): Folder item. + """ + + super(LoaderFoldersModel, self)._fill_item_data(item, folder_item) + + +class LoaderFoldersWidget(QtWidgets.QWidget): + """Folders widget. + + Widget that handles folders view, model and selection. + + Expected selection handling is disabled by default. If enabled, the + widget will handle the expected in predefined way. Widget is listening + to event 'expected_selection_changed' with expected event data below, + the same data must be available when called method + 'get_expected_selection_data' on controller. + + { + "folder": { + "current": bool, # Folder is what should be set now + "folder_id": Union[str, None], # Folder id that should be selected + }, + ... + } + + Selection is confirmed by calling method 'expected_folder_selected' on + controller. + + + Args: + controller (AbstractWorkfilesFrontend): The control object. + parent (QtWidgets.QWidget): The parent widget. + handle_expected_selection (bool): If True, the widget will handle + the expected selection. Defaults to False. + """ + + def __init__(self, controller, parent, handle_expected_selection=False): + super(LoaderFoldersWidget, self).__init__(parent) + + folders_view = DeselectableTreeView(self) + folders_view.setHeaderHidden(True) + folders_view.setSelectionMode( + QtWidgets.QAbstractItemView.ExtendedSelection) + + folders_model = LoaderFoldersModel(controller) + folders_proxy_model = RecursiveSortFilterProxyModel() + folders_proxy_model.setSourceModel(folders_model) + + folders_view.setModel(folders_proxy_model) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(folders_view, 1) + + controller.register_event_callback( + "selection.project.changed", + self._on_project_selection_change, + ) + controller.register_event_callback( + "folders.refresh.finished", + self._on_folders_refresh_finished + ) + controller.register_event_callback( + "controller.refresh.finished", + self._on_controller_refresh + ) + controller.register_event_callback( + "expected_selection_changed", + self._on_expected_selection_change + ) + + selection_model = folders_view.selectionModel() + selection_model.selectionChanged.connect(self._on_selection_change) + + folders_model.refreshed.connect(self._on_model_refresh) + + self._controller = controller + self._folders_view = folders_view + self._folders_model = folders_model + self._folders_proxy_model = folders_proxy_model + + self._handle_expected_selection = handle_expected_selection + self._expected_selection = None + + def set_name_filer(self, name): + """Set filter of folder name. + + Args: + name (str): The string filter. + """ + + self._folders_proxy_model.setFilterFixedString(name) + + def _on_project_selection_change(self, event): + project_name = event["project_name"] + self._set_project_name(project_name) + + def _set_project_name(self, project_name): + self._folders_model.set_project_name(project_name) + + def _clear(self): + self._folders_model.clear() + + def _on_folders_refresh_finished(self, event): + if event["sender"] != FOLDERS_MODEL_SENDER_NAME: + self._set_project_name(event["project_name"]) + + def _on_controller_refresh(self): + self._update_expected_selection() + + def _on_model_refresh(self): + if self._expected_selection: + self._set_expected_selection() + self._folders_proxy_model.sort(0) + + def _get_selected_item_ids(self): + selection_model = self._folders_view.selectionModel() + item_ids = [] + for index in selection_model.selectedIndexes(): + item_id = index.data(ITEM_ID_ROLE) + if item_id is not None: + item_ids.append(item_id) + return item_ids + + def _on_selection_change(self): + item_ids = self._get_selected_item_ids() + self._controller.set_selected_folders(item_ids) + + # Expected selection handling + def _on_expected_selection_change(self, event): + self._update_expected_selection(event.data) + + def _update_expected_selection(self, expected_data=None): + if not self._handle_expected_selection: + return + + if expected_data is None: + expected_data = self._controller.get_expected_selection_data() + + folder_data = expected_data.get("folder") + if not folder_data or not folder_data["current"]: + return + + folder_id = folder_data["id"] + self._expected_selection = folder_id + if not self._folders_model.is_refreshing: + self._set_expected_selection() + + def _set_expected_selection(self): + if not self._handle_expected_selection: + return + + folder_id = self._expected_selection + selected_ids = self._get_selected_item_ids() + self._expected_selection = None + skip_selection = ( + folder_id is None + or ( + folder_id in selected_ids + and len(selected_ids) == 1 + ) + ) + if not skip_selection: + index = self._folders_model.get_index_by_id(folder_id) + if index.isValid(): + proxy_index = self._folders_proxy_model.mapFromSource(index) + self._folders_view.setCurrentIndex(proxy_index) + self._controller.expected_folder_selected(folder_id) diff --git a/openpype/tools/ayon_loader/ui/products_delegates.py b/openpype/tools/ayon_loader/ui/products_delegates.py new file mode 100644 index 00000000000..955544417f7 --- /dev/null +++ b/openpype/tools/ayon_loader/ui/products_delegates.py @@ -0,0 +1,163 @@ +import numbers +from qtpy import QtWidgets, QtCore, QtGui + +from openpype.pipeline import HeroVersionType +from openpype.tools.utils.lib import format_version + +from .products_model import ( + PRODUCT_ID_ROLE, + VERSION_NAME_EDIT_ROLE, + VERSION_ID_ROLE, +) + + +class VersionComboBox(QtWidgets.QComboBox): + value_changed = QtCore.Signal(str) + + def __init__(self, subset_id, parent): + super(VersionComboBox, self).__init__(parent) + self._subset_id = subset_id + self._items_by_id = {} + + self._current_id = None + + self.currentIndexChanged.connect(self._on_index_change) + + def update_versions(self, version_items, current_version_id): + model = self.model() + root_item = model.invisibleRootItem() + version_items = list(reversed(version_items)) + version_ids = [ + version_item.version_id + for version_item in version_items + ] + if current_version_id not in version_ids and version_ids: + current_version_id = version_ids[0] + self._current_id = current_version_id + + to_remove = set(self._items_by_id.keys()) - set(version_ids) + for item_id in to_remove: + item = self._items_by_id.pop(item_id) + root_item.removeRow(item.row()) + + for idx, version_item in enumerate(version_items): + version_id = version_item.version_id + + item = self._items_by_id.get(version_id) + if item is None: + label = format_version( + version_item.version, version_item.is_hero + ) + item = QtGui.QStandardItem(label) + item.setData(version_id, QtCore.Qt.UserRole) + self._items_by_id[version_id] = item + + if item.row() != idx: + root_item.insertRow(idx, item) + + index = version_ids.index(current_version_id) + if self.currentIndex() != index: + self.setCurrentIndex(index) + + def _on_index_change(self): + idx = self.currentIndex() + value = self.itemData(idx) + if value == self._current_id: + return + self._current_id = value + self.value_changed.emit(self._subset_id) + + +class VersionDelegate(QtWidgets.QStyledItemDelegate): + """A delegate that display version integer formatted as version string.""" + + version_changed = QtCore.Signal() + first_run = False + + def __init__(self, *args, **kwargs): + super(VersionDelegate, self).__init__(*args, **kwargs) + self._editor_by_subset_id = {} + + def displayText(self, value, locale): + if isinstance(value, HeroVersionType): + return format_version(value, True) + if not isinstance(value, numbers.Integral): + return "N/A" + return 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): + subset_id = index.data(PRODUCT_ID_ROLE) + if not subset_id: + return + + editor = VersionComboBox(subset_id, parent) + self._editor_by_subset_id[subset_id] = editor + editor.value_changed.connect(self._on_editor_change) + + return editor + + def _on_editor_change(self, subset_id): + editor = self._editor_by_subset_id[subset_id] + + # Update model data + self.commitData.emit(editor) + # Display model data + self.version_changed.emit() + + def setEditorData(self, editor, index): + editor.clear() + + # Current value of the index + versions = index.data(VERSION_NAME_EDIT_ROLE) or [] + version_id = index.data(VERSION_ID_ROLE) + editor.update_versions(versions, version_id) + + def setModelData(self, editor, model, index): + """Apply the integer version back in the model""" + + version_id = editor.itemData(editor.currentIndex()) + model.setData(index, version_id, VERSION_NAME_EDIT_ROLE) diff --git a/openpype/tools/ayon_loader/ui/products_model.py b/openpype/tools/ayon_loader/ui/products_model.py new file mode 100644 index 00000000000..08bb02e6f01 --- /dev/null +++ b/openpype/tools/ayon_loader/ui/products_model.py @@ -0,0 +1,353 @@ +import collections +from qtpy import QtGui, QtCore + +PRODUCTS_MODEL_SENDER_NAME = "qt_products_model" + +FOLDER_LABEL_ROLE = QtCore.Qt.UserRole + 1 +FOLDER_ID_ROLE = QtCore.Qt.UserRole + 2 +PRODUCT_ID_ROLE = QtCore.Qt.UserRole + 6 +PRODUCT_NAME_ROLE = QtCore.Qt.UserRole + 7 +PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 8 +VERSION_ID_ROLE = QtCore.Qt.UserRole + 9 +VERSION_HERO_ROLE = QtCore.Qt.UserRole + 10 +VERSION_NAME_ROLE = QtCore.Qt.UserRole + 11 +VERSION_NAME_EDIT_ROLE = QtCore.Qt.UserRole + 12 +VERSION_PUBLISH_TIME_ROLE = QtCore.Qt.UserRole + 13 +VERSION_AUTHOR_ROLE = QtCore.Qt.UserRole + 14 +VERSION_FRAME_RANGE_ROLE = QtCore.Qt.UserRole + 15 +VERSION_DURATION_ROLE = QtCore.Qt.UserRole + 16 +VERSION_HANDLES_ROLE = QtCore.Qt.UserRole + 17 +VERSION_STEP_ROLE = QtCore.Qt.UserRole + 18 +VERSION_IN_SCENE_ROLE = QtCore.Qt.UserRole + 19 +VERSION_AVAILABLE_ROLE = QtCore.Qt.UserRole + 20 + + +class ProductsModel(QtGui.QStandardItemModel): + column_labels = [ + "Product name", + "Product type", + "Folder", + "Version", + "Time", + "Author", + "Frames", + "Duration", + "Handles", + "Step", + "In scene", + "Availability", + ] + + version_col = column_labels.index("Version") + published_time_col = column_labels.index("Time") + + def __init__(self, controller): + super(ProductsModel, self).__init__() + self.setColumnCount(len(self.column_labels)) + # self.setColumnCount(5) + for idx, label in enumerate(self.column_labels): + self.setHeaderData(idx, QtCore.Qt.Horizontal, label) + self._controller = controller + + # Variables to store 'QStandardItem' + self._items_by_id = {} + self._group_items_by_name = {} + self._merged_items_by_id = {} + + # product item objects (they have version information) + self._product_items_by_id = {} + self._grouping_enabled = False + + def flags(self, index): + # Make the version column editable + if index.column() == self.version_col and index.data(PRODUCT_ID_ROLE): + return ( + QtCore.Qt.ItemIsEnabled + | QtCore.Qt.ItemIsSelectable + | QtCore.Qt.ItemIsEditable + ) + if index.column() != 0: + index = self.index(index.row(), 0, index.parent()) + return super(ProductsModel, self).flags(index) + + def data(self, index, role=None): + if role is None: + role = QtCore.Qt.DisplayRole + + if not index.isValid(): + return None + + col = index.column() + if col == 0: + return super(ProductsModel, self).data(index, role) + + if role == QtCore.Qt.DecorationRole: + return None + + if ( + role == VERSION_NAME_EDIT_ROLE + or (role == QtCore.Qt.EditRole and col == self.version_col) + ): + index = self.index(index.row(), 0, index.parent()) + product_id = index.data(PRODUCT_ID_ROLE) + product_item = self._product_items_by_id.get(product_id) + if product_item is None: + return None + return product_item.versions + + if role == QtCore.Qt.EditRole: + return None + + if role == QtCore.Qt.DisplayRole: + if not index.data(PRODUCT_ID_ROLE): + pass + elif col == col == self.version_col: + role = VERSION_NAME_ROLE + elif col == 1: + role = PRODUCT_TYPE_ROLE + elif col == 2: + role = FOLDER_LABEL_ROLE + elif col == 4: + role = VERSION_PUBLISH_TIME_ROLE + elif col == 5: + role = VERSION_AUTHOR_ROLE + elif col == 6: + role = VERSION_FRAME_RANGE_ROLE + elif col == 7: + role = VERSION_DURATION_ROLE + elif col == 8: + role = VERSION_HANDLES_ROLE + elif col == 9: + role = VERSION_STEP_ROLE + elif col == 10: + role = VERSION_IN_SCENE_ROLE + elif col == 11: + role = VERSION_AVAILABLE_ROLE + else: + return None + + index = self.index(index.row(), 0, index.parent()) + + return super(ProductsModel, self).data(index, role) + + def setData(self, index, value, role=None): + if not index.isValid(): + return False + + if role is None: + role = QtCore.Qt.EditRole + + col = index.column() + if col == self.version_col and role == QtCore.Qt.EditRole: + role = VERSION_NAME_EDIT_ROLE + + if role == VERSION_NAME_EDIT_ROLE: + if col != 0: + index = self.index(index.row(), 0, index.parent()) + product_id = index.data(PRODUCT_ID_ROLE) + product_item = self._product_items_by_id[product_id] + version_item = product_item.get_version_by_id(value) + if version_item is None: + return False + if index.data(VERSION_ID_ROLE) == version_item.version_id: + return True + item = self.itemFromIndex(index) + self._set_version_data_to_product_item(item, version_item) + return True + return super(ProductsModel, self).setData(index, value, role) + + def _clear(self): + root_item = self.invisibleRootItem() + root_item.removeRows(0, root_item.rowCount()) + + self._items_by_id = {} + self._group_items_by_name = {} + self._merged_items_by_id = {} + + self._product_items_by_id = {} + + def _remove_items(self, items): + root_item = self.invisibleRootItem() + for item in items: + if item is None: + continue + row = item.row() + if row < 0: + continue + parent = item.parent() + if parent is None: + parent = root_item + parent.takeRow(row) + + def _get_group_model_item(self, group_name): + if group_name is None: + return self.invisibleRootItem() + + model_item = self._group_items_by_name.get(group_name) + if model_item is None: + model_item = QtGui.QStandardItem(group_name) + model_item.setColumnCount(self.columnCount()) + self._group_items_by_name[group_name] = model_item + return model_item + + def _get_merged_model_item(self, path): + model_item = self._merged_items_by_id.get(path) + if model_item is None: + model_item = QtGui.QStandardItem(path) + model_item.setColumnCount(self.columnCount()) + self._merged_items_by_id[path] = model_item + return model_item + + def _set_version_data_to_product_item(self, model_item, version_item): + """ + + Args: + model_item (QtGui.QStandardItem): Item which should have values + from version item. + version_item (VersionItem): Item from entities model with + information about version. + """ + + model_item.setData(version_item.version_id, VERSION_ID_ROLE) + model_item.setData(version_item.version, VERSION_NAME_ROLE) + model_item.setData(version_item.version_id, VERSION_ID_ROLE) + model_item.setData(version_item.is_hero, VERSION_HERO_ROLE) + model_item.setData( + version_item.published_time, VERSION_PUBLISH_TIME_ROLE + ) + model_item.setData(version_item.author, VERSION_AUTHOR_ROLE) + model_item.setData(version_item.frame_range, VERSION_FRAME_RANGE_ROLE) + model_item.setData(version_item.duration, VERSION_DURATION_ROLE) + model_item.setData(version_item.handles, VERSION_HANDLES_ROLE) + model_item.setData(version_item.step, VERSION_STEP_ROLE) + model_item.setData(version_item.in_scene, VERSION_IN_SCENE_ROLE) + + def _get_product_model_item(self, product_item): + model_item = self._items_by_id.get(product_item.product_id) + versions = list(product_item.version_items) + versions.sort() + last_version = versions[-1] + if model_item is None: + product_id = product_item.product_id + model_item = QtGui.QStandardItem(product_item.product_name) + model_item.setColumnCount(self.columnCount()) + model_item.setData(product_id, PRODUCT_ID_ROLE) + model_item.setData(product_item.product_name, PRODUCT_NAME_ROLE) + model_item.setData(product_item.product_type, PRODUCT_TYPE_ROLE) + model_item.setData(product_item.folder_id, FOLDER_ID_ROLE) + model_item.setData(product_item.folder_label, FOLDER_LABEL_ROLE) + + self._product_items_by_id[product_id] = product_item + self._items_by_id[product_id] = model_item + self._set_version_data_to_product_item(model_item, last_version) + return model_item + + def refresh(self, project_name, folder_ids): + product_items = self._controller.get_product_items( + project_name, + folder_ids, + sender=PRODUCTS_MODEL_SENDER_NAME + ) + product_items_by_id = { + product_item.product_id: product_item + for product_item in product_items + } + # Remove product items that are not available + product_ids_to_remove = ( + set(self._items_by_id.keys()) - set(product_items_by_id.keys()) + ) + items_to_remove = [ + self._items_by_id.pop(product_id) + for product_id in product_ids_to_remove + ] + ( + self._product_items_by_id.pop(product_id) + for product_id in product_ids_to_remove + ) + self._remove_items(items_to_remove) + + # Prepare product groups + product_name_matches_by_group = collections.defaultdict(dict) + for product_item in product_items_by_id.values(): + group_name = None + if self._grouping_enabled: + group_name = product_item.group_name + + product_name = product_item.product_name + group = product_name_matches_by_group[group_name] + if product_name not in group: + group[product_name] = [product_item] + continue + group[product_name].append(product_item) + + group_names = set(product_name_matches_by_group.keys()) + has_root_items = None in group_names + if has_root_items: + group_names.remove(None) + s_group_names = list(sorted(group_names)) + if has_root_items: + s_group_names.insert(0, None) + + root_item = self.invisibleRootItem() + merged_paths = set() + for group_name in s_group_names: + key_parts = ["M"] + if group_name: + key_parts.append(group_name) + + groups = product_name_matches_by_group[group_name] + merged_product_items = {} + top_items = [] + for product_name, product_items in groups.items(): + if len(product_items) == 1: + top_items.append(product_items[0]) + else: + path = "/".join(key_parts + [product_name]) + merged_paths.add(path) + merged_product_items[path] = product_items + + new_items = [] + parent_item = self._get_group_model_item(group_name) + c_parent_item = None + if parent_item is not root_item: + c_parent_item = parent_item + + for product_item in top_items: + item = self._get_product_model_item(product_item) + if ( + item.row() < 0 + or item.parent() is not c_parent_item + ): + new_items.append(item) + + for path, product_items in merged_product_items.items(): + merged_item = self._get_merged_model_item(path) + if merged_item.parent() is not parent_item: + new_items.append(merged_item) + + new_merged_items = [] + for product_item in product_items: + item = self._get_product_model_item(product_item) + if item.parent() is not merged_item: + new_merged_items.append(item) + + if new_merged_items: + merged_item.appendRows(new_merged_items) + + if new_items: + parent_item.appendRows(new_items) + + merged_item_ids_to_remove = ( + set(self._merged_items_by_id.keys()) - merged_paths + ) + self._remove_items( + self._merged_items_by_id.pop(item_id) + for item_id in merged_item_ids_to_remove + ) + group_names_to_remove = ( + set(self._group_items_by_name.keys()) - set(s_group_names) + ) + self._remove_items( + self._group_items_by_name.pop(group_name) + for group_name in group_names_to_remove + ) diff --git a/openpype/tools/ayon_loader/ui/products_widget.py b/openpype/tools/ayon_loader/ui/products_widget.py new file mode 100644 index 00000000000..3a88c5c31e8 --- /dev/null +++ b/openpype/tools/ayon_loader/ui/products_widget.py @@ -0,0 +1,105 @@ +from qtpy import QtWidgets + +from openpype.tools.utils.delegates import PrettyTimeDelegate +from openpype.tools.utils import ( + RecursiveSortFilterProxyModel, + DeselectableTreeView, +) + +from .products_model import ProductsModel, PRODUCTS_MODEL_SENDER_NAME +from .products_delegates import VersionDelegate + + +class ProductsWidget(QtWidgets.QWidget): + default_widths = ( + 200, # Product name + 90, # Product type + 130, # Folder label + 60, # Version + 125, # Time + 75, # Author + 75, # Frames + 60, # Duration + 55, # Handles + 10, # Step + 25, # Loaded in scene + 65, # Site info (maybe?) + ) + + def __init__(self, controller, parent): + super(ProductsWidget, self).__init__(parent) + + self._controller = controller + + products_view = DeselectableTreeView(self) + products_view.setSelectionMode( + QtWidgets.QAbstractItemView.ExtendedSelection + ) + products_model = ProductsModel(controller) + products_proxy_model = RecursiveSortFilterProxyModel() + products_proxy_model.setSourceModel(products_model) + + products_view.setModel(products_proxy_model) + + for idx, width in enumerate(self.default_widths): + products_view.setColumnWidth(idx, width) + + # version_delegate = VersionDelegate() + # products_view.setItemDelegateForColumn( + # products_model.version_col, version_delegate) + + time_delegate = PrettyTimeDelegate() + products_view.setItemDelegateForColumn( + products_model.published_time_col, time_delegate) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(products_view, 1) + + controller.register_event_callback( + "selection.folders.changed", + self._on_folders_selection_change, + ) + controller.register_event_callback( + "products.refresh.finished", + self._on_products_refresh_finished + ) + # controller.register_event_callback( + # "controller.refresh.finished", + # self._on_controller_refresh + # ) + # controller.register_event_callback( + # "expected_selection_changed", + # self._on_expected_selection_change + # ) + + self._products_view = products_view + self._products_model = products_model + self._products_proxy_model = products_proxy_model + + self._selected_project_name = None + self._selected_folder_ids = set() + + def set_name_filer(self, name): + """Set filter of product name. + + Args: + name (str): The string filter. + """ + + self._products_proxy_model.setFilterFixedString(name) + + def _refresh_model(self): + self._products_model.refresh( + self._selected_project_name, + self._selected_folder_ids + ) + + def _on_folders_selection_change(self, event): + self._selected_project_name = event["project_name"] + self._selected_folder_ids = event["folder_ids"] + self._refresh_model() + + def _on_products_refresh_finished(self, event): + if event["sender"] != PRODUCTS_MODEL_SENDER_NAME: + self._refresh_model() diff --git a/openpype/tools/ayon_loader/ui/window.py b/openpype/tools/ayon_loader/ui/window.py new file mode 100644 index 00000000000..270935ea61b --- /dev/null +++ b/openpype/tools/ayon_loader/ui/window.py @@ -0,0 +1,119 @@ +from qtpy import QtWidgets, QtCore, QtGui + +from openpype.resources import get_openpype_icon_filepath +from openpype.style import load_stylesheet +from openpype.tools.utils import PlaceholderLineEdit +from openpype.tools.ayon_utils.widgets import ProjectsCombobox +from openpype.tools.ayon_loader.control import LoaderController + +from .folders_widget import LoaderFoldersWidget +from .products_widget import ProductsWidget + + +class LoaderWindow(QtWidgets.QWidget): + def __init__(self, controller=None, parent=None): + super(LoaderWindow, self).__init__(parent) + + icon = QtGui.QIcon(get_openpype_icon_filepath()) + self.setWindowIcon(icon) + self.setWindowTitle("Loader") + self.setFocusPolicy(QtCore.Qt.StrongFocus) + self.setAttribute(QtCore.Qt.WA_DeleteOnClose, False) + + if controller is None: + controller = LoaderController() + + main_splitter = QtWidgets.QSplitter(self) + # Context selection widget + context_widget = QtWidgets.QWidget(main_splitter) + + projects_combobox = ProjectsCombobox(controller, context_widget) + projects_combobox.set_select_item_visible(True) + + folders_filter_input = PlaceholderLineEdit(context_widget) + folders_filter_input.setPlaceholderText("Folder name filter...") + + folders_widget = LoaderFoldersWidget(controller, context_widget) + + context_layout = QtWidgets.QVBoxLayout(context_widget) + context_layout.setContentsMargins(0, 0, 0, 0) + context_layout.addWidget(projects_combobox, 0) + context_layout.addWidget(folders_filter_input, 0) + context_layout.addWidget(folders_widget, 1) + + # Subset + version selection item + products_wrap_widget = QtWidgets.QWidget(main_splitter) + + products_filter_input = PlaceholderLineEdit(products_wrap_widget) + products_filter_input.setPlaceholderText("Product name filter...") + + products_widget = ProductsWidget(controller, products_wrap_widget) + + products_wrap_layout = QtWidgets.QVBoxLayout(products_wrap_widget) + products_wrap_layout.setContentsMargins(0, 0, 0, 0) + products_wrap_layout.addWidget(products_filter_input, 0) + products_wrap_layout.addWidget(products_widget, 1) + + main_splitter.addWidget(context_widget) + main_splitter.addWidget(products_wrap_widget) + + main_splitter.setStretchFactor(0, 3) + main_splitter.setStretchFactor(1, 7) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.addWidget(main_splitter) + + show_timer = QtCore.QTimer() + show_timer.setInterval(1) + + show_timer.timeout.connect(self._on_show_timer) + + folders_filter_input.textChanged.connect( + self._on_folder_filete_change) + + self._projects_combobox = projects_combobox + + self._folders_filter_input = folders_filter_input + self._folders_widget = folders_widget + + self._products_filter_input = products_filter_input + self._products_widget = products_widget + + self._controller = controller + self._first_show = True + self._reset_on_show = True + self._show_counter = 0 + self._show_timer = show_timer + + def showEvent(self, event): + super(LoaderWindow, self).showEvent(event) + + if self._first_show: + self._on_first_show() + + self._show_timer.start() + + def _on_first_show(self): + self._first_show = False + # if self._controller.is_site_sync_enabled(): + # self.resize(1800, 900) + # else: + # self.resize(1300, 700) + self.resize(1300, 700) + self.setStyleSheet(load_stylesheet()) + self._controller.reset() + + def _on_show_timer(self): + if self._show_counter < 2: + self._show_counter += 1 + return + + self._show_counter = 0 + self._show_timer.stop() + + if self._reset_on_show: + self._reset_on_show = False + self._controller.reset() + + def _on_folder_filete_change(self, text): + self._folders_widget.set_name_filer(text) From 99543c28953a89ff858c2d032e70f99939486719 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 22 Sep 2023 12:03:43 +0200 Subject: [PATCH 02/69] tweaks in ayon utils --- openpype/tools/ayon_utils/models/hierarchy.py | 89 ++++++++++++++++--- openpype/tools/ayon_utils/widgets/__init__.py | 4 + .../ayon_utils/widgets/folders_widget.py | 27 ++++-- .../ayon_utils/widgets/projects_widget.py | 57 ++++++++++-- .../tools/ayon_utils/widgets/tasks_widget.py | 4 +- 5 files changed, 151 insertions(+), 30 deletions(-) diff --git a/openpype/tools/ayon_utils/models/hierarchy.py b/openpype/tools/ayon_utils/models/hierarchy.py index 93f4c48d982..585f09aec43 100644 --- a/openpype/tools/ayon_utils/models/hierarchy.py +++ b/openpype/tools/ayon_utils/models/hierarchy.py @@ -240,23 +240,65 @@ def get_task_items(self, project_name, folder_id, sender): self._refresh_tasks_cache(project_name, folder_id, sender) return task_cache.get_data() + def get_folder_entities(self, project_name, folder_ids): + """Get folder entities by ids. + + Args: + project_name (str): Project name. + folder_ids (Iterable[str]): Folder ids. + + Returns: + dict[str, Any]: Folder entities by id. + """ + + output = {} + folder_ids = set(folder_ids) + if not project_name or not folder_ids: + return output + + folder_ids_to_query = set() + for folder_id in folder_ids: + cache = self._folders_by_id[project_name][folder_id] + if cache.is_valid: + output[folder_id] = cache.get_data() + elif folder_id: + folder_ids_to_query.add(folder_id) + else: + output[folder_id] = None + self._query_folder_entities(project_name, folder_ids_to_query) + for folder_id in folder_ids_to_query: + cache = self._folders_by_id[project_name][folder_id] + output[folder_id] = cache.get_data() + return output + def get_folder_entity(self, project_name, folder_id): - cache = self._folders_by_id[project_name][folder_id] - if not cache.is_valid: - entity = None - if folder_id: - entity = ayon_api.get_folder_by_id(project_name, folder_id) - cache.update_data(entity) - return cache.get_data() + output = self.get_folder_entities(project_name, {folder_id}) + return output[folder_id] + + def get_task_entities(self, project_name, task_ids): + output = {} + task_ids = set(task_ids) + if not project_name or not task_ids: + return output + + task_ids_to_query = set() + for task_id in task_ids: + cache = self._tasks_by_id[project_name][task_id] + if cache.is_valid: + output[task_id] = cache.get_data() + elif task_id: + task_ids_to_query.add(task_id) + else: + output[task_id] = None + self._query_task_entities(project_name, task_ids_to_query) + for task_id in task_ids_to_query: + cache = self._tasks_by_id[project_name][task_id] + output[task_id] = cache.get_data() + return output def get_task_entity(self, project_name, task_id): - cache = self._tasks_by_id[project_name][task_id] - if not cache.is_valid: - entity = None - if task_id: - entity = ayon_api.get_task_by_id(project_name, task_id) - cache.update_data(entity) - return cache.get_data() + output = self.get_task_entities(project_name, {task_id}) + return output[task_id] @contextlib.contextmanager def _folder_refresh_event_manager(self, project_name, sender): @@ -326,6 +368,25 @@ def _query_folders(self, project_name): hierachy_queue.extend(item["children"] or []) return folder_items + def _query_folder_entities(self, project_name, folder_ids): + if not project_name or not folder_ids: + return + project_cache = self._folders_by_id[project_name] + folders = ayon_api.get_folders(project_name, folder_ids=folder_ids) + for folder in folders: + folder_id = folder["id"] + project_cache[folder_id].update_data(folder) + + def _query_task_entities(self, project_name, task_ids): + if not project_name or not task_ids: + return + + project_cache = self._tasks_by_id[project_name] + tasks = ayon_api.get_folders(project_name, task_ids=task_ids) + for task in tasks: + task_id = task["id"] + project_cache[task_id].update_data(task) + def _refresh_tasks_cache(self, project_name, folder_id, sender=None): if folder_id in self._tasks_refreshing: return diff --git a/openpype/tools/ayon_utils/widgets/__init__.py b/openpype/tools/ayon_utils/widgets/__init__.py index 59aef98fafb..432a249a734 100644 --- a/openpype/tools/ayon_utils/widgets/__init__.py +++ b/openpype/tools/ayon_utils/widgets/__init__.py @@ -8,11 +8,13 @@ from .folders_widget import ( FoldersWidget, FoldersModel, + FOLDERS_MODEL_SENDER_NAME, ) from .tasks_widget import ( TasksWidget, TasksModel, + TASKS_MODEL_SENDER_NAME, ) from .utils import ( get_qt_icon, @@ -28,9 +30,11 @@ "FoldersWidget", "FoldersModel", + "FOLDERS_MODEL_SENDER_NAME", "TasksWidget", "TasksModel", + "TASKS_MODEL_SENDER_NAME", "get_qt_icon", "RefreshThread", diff --git a/openpype/tools/ayon_utils/widgets/folders_widget.py b/openpype/tools/ayon_utils/widgets/folders_widget.py index 4f448810810..b57ffb126a9 100644 --- a/openpype/tools/ayon_utils/widgets/folders_widget.py +++ b/openpype/tools/ayon_utils/widgets/folders_widget.py @@ -9,7 +9,7 @@ from .utils import RefreshThread, get_qt_icon -SENDER_NAME = "qt_folders_model" +FOLDERS_MODEL_SENDER_NAME = "qt_folders_model" ITEM_ID_ROLE = QtCore.Qt.UserRole + 1 ITEM_NAME_ROLE = QtCore.Qt.UserRole + 2 @@ -112,7 +112,7 @@ def set_project_name(self, project_name): project_name, self._controller.get_folder_items, project_name, - SENDER_NAME + FOLDERS_MODEL_SENDER_NAME ) self._current_refresh_thread = thread self._refresh_threads[thread.id] = thread @@ -142,6 +142,21 @@ def _on_refresh_thread(self, thread_id): self._fill_items(thread.get_result()) + def _fill_item_data(self, item, folder_item): + """ + + Args: + item (QtGui.QStandardItem): Item to fill data. + folder_item (FolderItem): Folder item. + """ + + icon = get_qt_icon(folder_item.icon) + item.setData(folder_item.entity_id, ITEM_ID_ROLE) + item.setData(folder_item.name, ITEM_NAME_ROLE) + item.setData(folder_item.label, QtCore.Qt.DisplayRole) + item.setData(icon, QtCore.Qt.DecorationRole) + + def _fill_items(self, folder_items_by_id): if not folder_items_by_id: if folder_items_by_id is not None: @@ -195,11 +210,7 @@ def _fill_items(self, folder_items_by_id): else: is_new = self._parent_id_by_id[item_id] != parent_id - icon = get_qt_icon(folder_item.icon) - item.setData(item_id, ITEM_ID_ROLE) - item.setData(folder_item.name, ITEM_NAME_ROLE) - item.setData(folder_item.label, QtCore.Qt.DisplayRole) - item.setData(icon, QtCore.Qt.DecorationRole) + self._fill_item_data(item, folder_item) if is_new: new_items.append(item) self._items_by_id[item_id] = item @@ -320,7 +331,7 @@ def _set_project_name(self, project_name): self._folders_model.set_project_name(project_name) def _on_folders_refresh_finished(self, event): - if event["sender"] != SENDER_NAME: + if event["sender"] != FOLDERS_MODEL_SENDER_NAME: self._set_project_name(event["project_name"]) def _on_controller_refresh(self): diff --git a/openpype/tools/ayon_utils/widgets/projects_widget.py b/openpype/tools/ayon_utils/widgets/projects_widget.py index 818d5749107..08166c4c9dc 100644 --- a/openpype/tools/ayon_utils/widgets/projects_widget.py +++ b/openpype/tools/ayon_utils/widgets/projects_widget.py @@ -19,6 +19,11 @@ def __init__(self, controller): self._empty_item = None self._empty_item_added = False + self._select_item = None + self._select_item_added = False + + self._select_item_visible = None + self._is_refreshing = False self._refresh_thread = None @@ -32,6 +37,12 @@ def refresh(self): def has_content(self): return len(self._project_items) > 0 + def set_select_item_visible(self, visible): + if self._select_item_visible is visible: + return + self._select_item_visible = visible + self._add_select_item() + def _add_empty_item(self): item = self._get_empty_item() if not self._empty_item_added: @@ -55,6 +66,29 @@ def _get_empty_item(self): self._empty_item = item return self._empty_item + def _add_select_item(self): + item = self._get_select_item() + if not self._select_item_added: + root_item = self.invisibleRootItem() + root_item.appendRow(item) + self._select_item_added = True + + def _remove_select_item(self): + if not self._select_item_added: + return + + root_item = self.invisibleRootItem() + item = self._get_select_item() + root_item.takeRow(item.row()) + self._select_item_added = False + + def _get_select_item(self): + if self._select_item is None: + item = QtGui.QStandardItem("< Select project >") + item.setEditable(False) + self._select_item = item + return self._select_item + def _refresh(self): if self._is_refreshing: return @@ -88,6 +122,7 @@ def _fill_items(self, project_items): item = self._project_items.get(project_name) if item is None: item = QtGui.QStandardItem() + item.setEditable(False) new_items.append(item) icon = get_qt_icon(project_item.icon) item.setData(project_name, QtCore.Qt.DisplayRole) @@ -137,6 +172,9 @@ def lessThan(self, left_index, right_index): def filterAcceptsRow(self, source_row, source_parent): index = self.sourceModel().index(source_row, 0, source_parent) + project_name = index.data(PROJECT_NAME_ROLE) + if project_name is None: + return True string_pattern = self.filterRegularExpression().pattern() if ( self._filter_inactive @@ -145,9 +183,7 @@ def filterAcceptsRow(self, source_row, source_parent): return False if string_pattern: - project_name = index.data(PROJECT_IS_ACTIVE_ROLE) - if project_name is not None: - return string_pattern.lower() in project_name.lower() + return string_pattern.lower() in project_name.lower() return super(ProjectSortFilterProxy, self).filterAcceptsRow( source_row, source_parent @@ -159,10 +195,10 @@ def _custom_index_filter(self, index): def is_active_filter_enabled(self): return self._filter_inactive - def set_active_filter_enabled(self, value): - if self._filter_inactive == value: + def set_active_filter_enabled(self, enabled): + if self._filter_inactive == enabled: return - self._filter_inactive = value + self._filter_inactive = enabled self.invalidateFilter() @@ -264,6 +300,15 @@ def get_current_project_name(self): return None return self._projects_combobox.itemData(idx, PROJECT_NAME_ROLE) + def set_select_item_visible(self, visible): + self._projects_model.set_select_item_visible(visible) + + def is_active_filter_enabled(self): + return self._projects_proxy_model.is_active_filter_enabled() + + def set_active_filter_enabled(self, enabled): + return self._projects_proxy_model.set_active_filter_enabled(enabled) + def _on_current_index_changed(self, idx): if not self._listen_selection_change: return diff --git a/openpype/tools/ayon_utils/widgets/tasks_widget.py b/openpype/tools/ayon_utils/widgets/tasks_widget.py index 0af506863a9..da745bd8108 100644 --- a/openpype/tools/ayon_utils/widgets/tasks_widget.py +++ b/openpype/tools/ayon_utils/widgets/tasks_widget.py @@ -5,7 +5,7 @@ from .utils import RefreshThread, get_qt_icon -SENDER_NAME = "qt_tasks_model" +TASKS_MODEL_SENDER_NAME = "qt_tasks_model" ITEM_ID_ROLE = QtCore.Qt.UserRole + 1 PARENT_ID_ROLE = QtCore.Qt.UserRole + 2 ITEM_NAME_ROLE = QtCore.Qt.UserRole + 3 @@ -362,7 +362,7 @@ def _on_tasks_refresh_finished(self, event): # Refresh only if current folder id is the same if ( - event["sender"] == SENDER_NAME + event["sender"] == TASKS_MODEL_SENDER_NAME or event["folder_id"] != self._selected_folder_id ): return From adab6d490ab30ad44da331efe358e9f1bb935fec Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 22 Sep 2023 17:10:31 +0200 Subject: [PATCH 03/69] implemented product type filtering --- openpype/tools/ayon_loader/abstract.py | 22 ++ openpype/tools/ayon_loader/control.py | 3 + openpype/tools/ayon_loader/models/products.py | 31 ++- .../ayon_loader/ui/product_types_widget.py | 220 ++++++++++++++++++ .../tools/ayon_loader/ui/products_model.py | 3 +- .../tools/ayon_loader/ui/products_widget.py | 77 ++++-- openpype/tools/ayon_loader/ui/window.py | 17 +- 7 files changed, 356 insertions(+), 17 deletions(-) create mode 100644 openpype/tools/ayon_loader/ui/product_types_widget.py diff --git a/openpype/tools/ayon_loader/abstract.py b/openpype/tools/ayon_loader/abstract.py index 7b8f0b696cc..8f0de520315 100644 --- a/openpype/tools/ayon_loader/abstract.py +++ b/openpype/tools/ayon_loader/abstract.py @@ -118,6 +118,24 @@ def from_data(cls, data): return cls(**data) +class ProductTypeItem: + def __init__(self, name, icon, checked): + self.name = name + self.icon = icon + self.checked = checked + + def to_data(self): + return { + "name": self.name, + "icon": self.icon, + "checked": self.checked, + } + + @classmethod + def from_data(cls, data): + return cls(**data) + + @six.add_metaclass(ABCMeta) class AbstractController: @abstractmethod @@ -133,6 +151,10 @@ def reset(self): def get_project_items(self): pass + @abstractmethod + def get_product_type_items(self, project_name): + pass + # Selection model wrapper calls @abstractmethod def get_selected_project_name(self): diff --git a/openpype/tools/ayon_loader/control.py b/openpype/tools/ayon_loader/control.py index 1ca19fcf77b..4952bdc6c30 100644 --- a/openpype/tools/ayon_loader/control.py +++ b/openpype/tools/ayon_loader/control.py @@ -59,6 +59,9 @@ def get_product_items(self, project_name, folder_ids, sender=None): return self._products_model.get_product_items( project_name, folder_ids, sender) + def get_product_type_items(self, project_name): + return self._products_model.get_product_type_items(project_name) + def get_folder_entity(self, project_name, folder_id): self._hierarchy_model.get_folder_entity(project_name, folder_id) diff --git a/openpype/tools/ayon_loader/models/products.py b/openpype/tools/ayon_loader/models/products.py index f39be1d6379..b7e2afb6a4e 100644 --- a/openpype/tools/ayon_loader/models/products.py +++ b/openpype/tools/ayon_loader/models/products.py @@ -5,7 +5,11 @@ import ayon_api from openpype.tools.ayon_utils.models import NestedCacheItem -from openpype.tools.ayon_loader.abstract import VersionItem, ProductItem +from openpype.tools.ayon_loader.abstract import ( + VersionItem, + ProductItem, + ProductTypeItem, +) PRODUCTS_MODEL_SENDER = "products.model" @@ -77,6 +81,19 @@ def product_item_from_entity(product_entity, version_entities, folder_label): ) +def product_type_item_from_data(product_type_data): + # TODO implement icon implementation + # icon = product_type_data["icon"] + # color = product_type_data["color"] + icon = { + "type": "awesome-font", + "name": "fa.folder", + "color": "#0091B2", + } + # TODO implement checked logic + return ProductTypeItem(product_type_data["name"], icon, True) + + class RepreItem: def __init__(self, repre_id, version_id): self.repre_id = repre_id @@ -94,6 +111,8 @@ class ProductsModel: def __init__(self, controller): self._controller = controller + self._product_type_items_cache = NestedCacheItem( + levels=1, default_factory=list) self._product_items_cache = NestedCacheItem( levels=2, default_factory=dict) self._repre_items_cache = NestedCacheItem( @@ -103,6 +122,16 @@ def reset(self): self._product_items_cache.reset() self._repre_items_cache.reset() + def get_product_type_items(self, project_name): + cache = self._product_type_items_cache[project_name] + if not cache.is_valid: + product_types = ayon_api.get_project_product_types(project_name) + cache.update_data([ + product_type_item_from_data(product_type) + for product_type in product_types + ]) + return cache.get_data() + def get_product_items(self, project_name, folder_ids, sender): if not project_name or not folder_ids: return [] diff --git a/openpype/tools/ayon_loader/ui/product_types_widget.py b/openpype/tools/ayon_loader/ui/product_types_widget.py new file mode 100644 index 00000000000..a84a7ff8465 --- /dev/null +++ b/openpype/tools/ayon_loader/ui/product_types_widget.py @@ -0,0 +1,220 @@ +from qtpy import QtWidgets, QtGui, QtCore + +from openpype.tools.ayon_utils.widgets import get_qt_icon + +PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 1 + + +class ProductTypesQtModel(QtGui.QStandardItemModel): + refreshed = QtCore.Signal() + filter_changed = QtCore.Signal() + + def __init__(self, controller): + super(ProductTypesQtModel, self).__init__() + self._controller = controller + + self._refreshing = False + self._bulk_change = False + self._items_by_name = {} + + def is_refreshing(self): + return self._refreshing + + def get_filter_info(self): + """Product types filtering info. + + Returns: + dict[str, bool]: Filtering value by product type name. False value + means to hide product type. + """ + + return { + name: item.checkState() == QtCore.Qt.Checked + for name, item in self._items_by_name.items() + } + + def refresh(self, project_name): + self._refreshing = True + product_type_items = self._controller.get_product_type_items( + project_name) + + items_to_remove = set(self._items_by_name.keys()) + new_items = [] + for product_type_item in product_type_items: + name = product_type_item.name + items_to_remove.discard(name) + item = self._items_by_name.get(product_type_item.name) + if item is None: + item = QtGui.QStandardItem(name) + item.setData(name, PRODUCT_TYPE_ROLE) + item.setEditable(False) + item.setCheckable(True) + new_items.append(item) + self._items_by_name[name] = item + + item.setCheckState( + QtCore.Qt.Checked + if product_type_item.checked + else QtCore.Qt.Unchecked + ) + icon = get_qt_icon(product_type_item.icon) + item.setData(icon, QtCore.Qt.DecorationRole) + + root_item = self.invisibleRootItem() + if new_items: + root_item.appendRows(new_items) + + for name in items_to_remove: + item = self._items_by_name.pop(name) + root_item.removeRow(item.row()) + + self._refreshing = False + self.refreshed.emit() + + def setData(self, index, value, role=None): + checkstate_changed = False + if role is None: + role = QtCore.Qt.EditRole + elif role == QtCore.Qt.CheckStateRole: + checkstate_changed = True + output = super(ProductTypesQtModel, self).setData(index, value, role) + if checkstate_changed and not self._bulk_change: + self.filter_changed.emit() + return output + + def change_state_for_all(self, checked): + if self._items_by_name: + self.change_states(checked, self._items_by_name.keys()) + + def change_states(self, checked, product_types): + product_types = set(product_types) + if not product_types: + return + + if checked is None: + state = None + elif checked: + state = QtCore.Qt.Checked + else: + state = QtCore.Qt.Unchecked + + self._bulk_change = True + + changed = False + for product_type in product_types: + item = self._items_by_name.get(product_type) + if item is None: + continue + new_state = state + item_checkstate = item.checkState() + if new_state is None: + if item_checkstate == QtCore.Qt.Checked: + new_state = QtCore.Qt.Unchecked + else: + new_state = QtCore.Qt.Checked + elif item_checkstate == new_state: + continue + changed = True + item.setCheckState(new_state) + + self._bulk_change = False + + if changed: + self.filter_changed.emit() + + +class ProductTypesView(QtWidgets.QListView): + filter_changed = QtCore.Signal() + + def __init__(self, controller, parent): + super(ProductTypesView, self).__init__(parent) + + self.setSelectionMode( + QtWidgets.QAbstractItemView.ExtendedSelection + ) + self.setAlternatingRowColors(True) + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + + product_types_model = ProductTypesQtModel(controller) + product_types_proxy_model = QtCore.QSortFilterProxyModel() + product_types_proxy_model.setSourceModel(product_types_model) + + self.setModel(product_types_proxy_model) + + product_types_model.refreshed.connect(self._on_refresh_finished) + product_types_model.filter_changed.connect(self._on_filter_change) + self.customContextMenuRequested.connect(self._on_context_menu) + + controller.register_event_callback( + "selection.project.changed", + self._on_project_change + ) + + self._controller = controller + + self._product_types_model = product_types_model + self._product_types_proxy_model = product_types_proxy_model + + def get_filter_info(self): + return self._product_types_model.get_filter_info() + + def _on_project_change(self, event): + project_name = event["project_name"] + self._product_types_model.refresh(project_name) + + def _on_refresh_finished(self): + self.filter_changed.emit() + + def _on_filter_change(self): + if not self._product_types_model.is_refreshing(): + self.filter_changed.emit() + + def _change_selection_state(self, checkstate): + selection_model = self.selectionModel() + product_types = { + index.data(PRODUCT_TYPE_ROLE) + for index in selection_model.selectedIndexes() + } + product_types.discard(None) + self._product_types_model.change_states(checkstate, product_types) + + def _on_enable_all(self): + self._product_types_model.change_state_for_all(True) + + def _on_disable_all(self): + self._product_types_model.change_state_for_all(False) + + def _on_context_menu(self, pos): + menu = QtWidgets.QMenu(self) + + # Add enable all action + action_check_all = QtWidgets.QAction(menu) + action_check_all.setText("Enable All") + action_check_all.triggered.connect(self._on_enable_all) + # Add disable all action + action_uncheck_all = QtWidgets.QAction(menu) + action_uncheck_all.setText("Disable All") + action_uncheck_all.triggered.connect(self._on_disable_all) + + menu.addAction(action_check_all) + menu.addAction(action_uncheck_all) + + # Get mouse position + global_pos = self.viewport().mapToGlobal(pos) + menu.exec_(global_pos) + + def event(self, event): + if event.type() == QtCore.QEvent.KeyPress: + if event.key() == QtCore.Qt.Key_Space: + self._change_selection_state(None) + return True + + if event.key() == QtCore.Qt.Key_Backspace: + self._change_selection_state(False) + return True + + if event.key() == QtCore.Qt.Key_Return: + self._change_selection_state(True) + return True + + return super(ProductTypesView, self).event(event) diff --git a/openpype/tools/ayon_loader/ui/products_model.py b/openpype/tools/ayon_loader/ui/products_model.py index 08bb02e6f01..a90327c4853 100644 --- a/openpype/tools/ayon_loader/ui/products_model.py +++ b/openpype/tools/ayon_loader/ui/products_model.py @@ -40,11 +40,11 @@ class ProductsModel(QtGui.QStandardItemModel): version_col = column_labels.index("Version") published_time_col = column_labels.index("Time") + folders_label_col = column_labels.index("Folder") def __init__(self, controller): super(ProductsModel, self).__init__() self.setColumnCount(len(self.column_labels)) - # self.setColumnCount(5) for idx, label in enumerate(self.column_labels): self.setHeaderData(idx, QtCore.Qt.Horizontal, label) self._controller = controller @@ -256,6 +256,7 @@ def refresh(self, project_name, folder_ids): product_ids_to_remove = ( set(self._items_by_id.keys()) - set(product_items_by_id.keys()) ) + items_to_remove = [ self._items_by_id.pop(product_id) for product_id in product_ids_to_remove diff --git a/openpype/tools/ayon_loader/ui/products_widget.py b/openpype/tools/ayon_loader/ui/products_widget.py index 3a88c5c31e8..6b5017fa025 100644 --- a/openpype/tools/ayon_loader/ui/products_widget.py +++ b/openpype/tools/ayon_loader/ui/products_widget.py @@ -6,24 +6,48 @@ DeselectableTreeView, ) -from .products_model import ProductsModel, PRODUCTS_MODEL_SENDER_NAME +from .products_model import ( + ProductsModel, + PRODUCTS_MODEL_SENDER_NAME, + PRODUCT_TYPE_ROLE, +) from .products_delegates import VersionDelegate +class ProductsProxyModel(RecursiveSortFilterProxyModel): + def __init__(self, parent=None): + super(ProductsProxyModel, self).__init__(parent) + + self._product_type_filters = {} + + def set_product_type_filters(self, product_type_filters): + self._product_type_filters = product_type_filters + self.invalidateFilter() + + def filterAcceptsRow(self, source_row, source_parent): + source_model = self.sourceModel() + index = source_model.index(source_row, 0, source_parent) + product_type = source_model.data(index, PRODUCT_TYPE_ROLE) + if not self._product_type_filters.get(product_type, True): + return False + return super(ProductsProxyModel, self).filterAcceptsRow( + source_row, source_parent) + + class ProductsWidget(QtWidgets.QWidget): default_widths = ( - 200, # Product name - 90, # Product type - 130, # Folder label - 60, # Version - 125, # Time - 75, # Author - 75, # Frames - 60, # Duration - 55, # Handles - 10, # Step - 25, # Loaded in scene - 65, # Site info (maybe?) + 200, # Product name + 90, # Product type + 130, # Folder label + 60, # Version + 125, # Time + 75, # Author + 75, # Frames + 60, # Duration + 55, # Handles + 10, # Step + 25, # Loaded in scene + 65, # Site info (maybe?) ) def __init__(self, controller, parent): @@ -35,8 +59,10 @@ def __init__(self, controller, parent): products_view.setSelectionMode( QtWidgets.QAbstractItemView.ExtendedSelection ) + products_view.setAlternatingRowColors(True) + products_model = ProductsModel(controller) - products_proxy_model = RecursiveSortFilterProxyModel() + products_proxy_model = ProductsProxyModel() products_proxy_model.setSourceModel(products_model) products_view.setModel(products_proxy_model) @@ -80,6 +106,9 @@ def __init__(self, controller, parent): self._selected_project_name = None self._selected_folder_ids = set() + # Set initial state of widget + self._update_folders_label_visible() + def set_name_filer(self, name): """Set filter of product name. @@ -89,6 +118,18 @@ def set_name_filer(self, name): self._products_proxy_model.setFilterFixedString(name) + def set_product_type_filter(self, product_type_filters): + """ + + Args: + product_type_filters (dict[str, bool]): The filter of product + types. + """ + + self._products_proxy_model.set_product_type_filters( + product_type_filters + ) + def _refresh_model(self): self._products_model.refresh( self._selected_project_name, @@ -99,6 +140,14 @@ def _on_folders_selection_change(self, event): self._selected_project_name = event["project_name"] self._selected_folder_ids = event["folder_ids"] self._refresh_model() + self._update_folders_label_visible() + + def _update_folders_label_visible(self): + folders_label_hidden = len(self._selected_folder_ids) <= 1 + self._products_view.setColumnHidden( + self._products_model.folders_label_col, + folders_label_hidden + ) def _on_products_refresh_finished(self, event): if event["sender"] != PRODUCTS_MODEL_SENDER_NAME: diff --git a/openpype/tools/ayon_loader/ui/window.py b/openpype/tools/ayon_loader/ui/window.py index 270935ea61b..bb13ff773db 100644 --- a/openpype/tools/ayon_loader/ui/window.py +++ b/openpype/tools/ayon_loader/ui/window.py @@ -8,6 +8,7 @@ from .folders_widget import LoaderFoldersWidget from .products_widget import ProductsWidget +from .product_types_widget import ProductTypesView class LoaderWindow(QtWidgets.QWidget): @@ -35,11 +36,14 @@ def __init__(self, controller=None, parent=None): folders_widget = LoaderFoldersWidget(controller, context_widget) + product_types_widget = ProductTypesView(controller, context_widget) + context_layout = QtWidgets.QVBoxLayout(context_widget) context_layout.setContentsMargins(0, 0, 0, 0) context_layout.addWidget(projects_combobox, 0) context_layout.addWidget(folders_filter_input, 0) context_layout.addWidget(folders_widget, 1) + context_layout.addWidget(product_types_widget, 1) # Subset + version selection item products_wrap_widget = QtWidgets.QWidget(main_splitter) @@ -69,13 +73,19 @@ def __init__(self, controller=None, parent=None): show_timer.timeout.connect(self._on_show_timer) folders_filter_input.textChanged.connect( - self._on_folder_filete_change) + self._on_folder_filete_change + ) + product_types_widget.filter_changed.connect( + self._on_product_type_filter_change + ) self._projects_combobox = projects_combobox self._folders_filter_input = folders_filter_input self._folders_widget = folders_widget + self._product_types_widget = product_types_widget + self._products_filter_input = products_filter_input self._products_widget = products_widget @@ -117,3 +127,8 @@ def _on_show_timer(self): def _on_folder_filete_change(self, text): self._folders_widget.set_name_filer(text) + + def _on_product_type_filter_change(self): + self._products_widget.set_product_type_filter( + self._product_types_widget.get_filter_info() + ) From 6a0b4dbf8d09dd5a14698121dc9b098c8d913a19 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 22 Sep 2023 17:38:23 +0200 Subject: [PATCH 04/69] products have icons and proper style --- openpype/tools/ayon_loader/abstract.py | 14 +++++--- openpype/tools/ayon_loader/models/products.py | 32 ++++++++++++++++--- .../tools/ayon_loader/ui/products_model.py | 20 +++++++++--- .../tools/ayon_loader/ui/products_widget.py | 9 +++++- 4 files changed, 61 insertions(+), 14 deletions(-) diff --git a/openpype/tools/ayon_loader/abstract.py b/openpype/tools/ayon_loader/abstract.py index 8f0de520315..c20547fa57d 100644 --- a/openpype/tools/ayon_loader/abstract.py +++ b/openpype/tools/ayon_loader/abstract.py @@ -81,17 +81,21 @@ def __init__( product_id, product_type, product_name, + product_icon, + product_type_icon, + group_name, folder_id, folder_label, - group_name, - version_items + version_items, ): self.product_id = product_id self.product_type = product_type self.product_name = product_name + self.product_icon = product_icon + self.product_type_icon = product_type_icon + self.group_name = group_name self.folder_id = folder_id self.folder_label = folder_label - self.group_name = group_name self.version_items = version_items def to_data(self): @@ -99,9 +103,11 @@ def to_data(self): "product_id": self.product_id, "product_type": self.product_type, "product_name": self.product_name, + "product_icon": self.product_icon, + "product_type_icon": self.product_type_icon, + "group_name": self.group_name, "folder_id": self.folder_id, "folder_label": self.folder_label, - "group_name": self.group_name, "version_items": [ version_item.to_data() for version_item in self.version_items diff --git a/openpype/tools/ayon_loader/models/products.py b/openpype/tools/ayon_loader/models/products.py index b7e2afb6a4e..aec0d85413e 100644 --- a/openpype/tools/ayon_loader/models/products.py +++ b/openpype/tools/ayon_loader/models/products.py @@ -4,6 +4,7 @@ import arrow import ayon_api +from openpype.style import get_default_entity_icon_color from openpype.tools.ayon_utils.models import NestedCacheItem from openpype.tools.ayon_loader.abstract import ( VersionItem, @@ -61,10 +62,23 @@ def version_item_from_entity(version): ) -def product_item_from_entity(product_entity, version_entities, folder_label): +def product_item_from_entity( + product_entity, + version_entities, + product_type_items_by_name, + folder_label, +): product_attribs = product_entity["attrib"] group = product_attribs.get("productGroup") + product_type = product_entity["productType"] + product_type_item = product_type_items_by_name[product_type] + product_type_icon = product_type_item.icon + product_icon = { + "type": "awesome-font", + "name": "fa.file-o", + "color": get_default_entity_icon_color(), + } version_items = [ version_item_from_entity(version_entity) for version_entity in version_entities @@ -72,11 +86,13 @@ def product_item_from_entity(product_entity, version_entities, folder_label): return ProductItem( product_id=product_entity["id"], - product_type=product_entity["productType"], + product_type=product_type, product_name=product_entity["name"], + product_icon=product_icon, + product_type_icon=product_type_icon, + group_name=group, folder_id=product_entity["folderId"], folder_label=folder_label, - group_name=group, version_items=version_items, ) @@ -171,6 +187,11 @@ def _refresh_product_items(self, project_name, folder_ids, sender): if not project_name or not folder_ids: return + product_type_items = self.get_product_type_items(project_name) + product_type_items_by_name = { + product_type_item.name: product_type_item + for product_type_item in product_type_items + } with self._product_refresh_event_manager( project_name, folder_ids, sender ): @@ -200,7 +221,10 @@ def _refresh_product_items(self, project_name, folder_ids, sender): if not versions: continue product_item = product_item_from_entity( - product, versions, folder_item.label + product, + versions, + product_type_items_by_name, + folder_item.label, ) items_by_folder_id[product_item.folder_id][product_id] = ( product_item diff --git a/openpype/tools/ayon_loader/ui/products_model.py b/openpype/tools/ayon_loader/ui/products_model.py index a90327c4853..78ff5e8b72b 100644 --- a/openpype/tools/ayon_loader/ui/products_model.py +++ b/openpype/tools/ayon_loader/ui/products_model.py @@ -1,13 +1,16 @@ import collections from qtpy import QtGui, QtCore +from openpype.tools.ayon_utils.widgets import get_qt_icon + PRODUCTS_MODEL_SENDER_NAME = "qt_products_model" FOLDER_LABEL_ROLE = QtCore.Qt.UserRole + 1 FOLDER_ID_ROLE = QtCore.Qt.UserRole + 2 -PRODUCT_ID_ROLE = QtCore.Qt.UserRole + 6 -PRODUCT_NAME_ROLE = QtCore.Qt.UserRole + 7 -PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 8 +PRODUCT_ID_ROLE = QtCore.Qt.UserRole + 5 +PRODUCT_NAME_ROLE = QtCore.Qt.UserRole + 6 +PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 7 +PRODUCT_TYPE_ICON_ROLE = QtCore.Qt.UserRole + 8 VERSION_ID_ROLE = QtCore.Qt.UserRole + 9 VERSION_HERO_ROLE = QtCore.Qt.UserRole + 10 VERSION_NAME_ROLE = QtCore.Qt.UserRole + 11 @@ -82,7 +85,10 @@ def data(self, index, role=None): return super(ProductsModel, self).data(index, role) if role == QtCore.Qt.DecorationRole: - return None + if col == 1: + role = PRODUCT_TYPE_ICON_ROLE + else: + return None if ( role == VERSION_NAME_EDIT_ROLE @@ -101,7 +107,7 @@ def data(self, index, role=None): if role == QtCore.Qt.DisplayRole: if not index.data(PRODUCT_ID_ROLE): pass - elif col == col == self.version_col: + elif col == self.version_col: role = VERSION_NAME_ROLE elif col == 1: role = PRODUCT_TYPE_ROLE @@ -230,10 +236,14 @@ def _get_product_model_item(self, product_item): if model_item is None: product_id = product_item.product_id model_item = QtGui.QStandardItem(product_item.product_name) + icon = get_qt_icon(product_item.product_icon) + product_type_icon = get_qt_icon(product_item.product_type_icon) model_item.setColumnCount(self.columnCount()) + model_item.setData(icon, QtCore.Qt.DecorationRole) model_item.setData(product_id, PRODUCT_ID_ROLE) model_item.setData(product_item.product_name, PRODUCT_NAME_ROLE) model_item.setData(product_item.product_type, PRODUCT_TYPE_ROLE) + model_item.setData(product_type_icon, PRODUCT_TYPE_ICON_ROLE) model_item.setData(product_item.folder_id, FOLDER_ID_ROLE) model_item.setData(product_item.folder_label, FOLDER_LABEL_ROLE) diff --git a/openpype/tools/ayon_loader/ui/products_widget.py b/openpype/tools/ayon_loader/ui/products_widget.py index 6b5017fa025..f7c0cf44d4b 100644 --- a/openpype/tools/ayon_loader/ui/products_widget.py +++ b/openpype/tools/ayon_loader/ui/products_widget.py @@ -1,4 +1,4 @@ -from qtpy import QtWidgets +from qtpy import QtWidgets, QtCore from openpype.tools.utils.delegates import PrettyTimeDelegate from openpype.tools.utils import ( @@ -56,9 +56,16 @@ def __init__(self, controller, parent): self._controller = controller products_view = DeselectableTreeView(self) + # TODO - define custom object name in style + products_view.setObjectName("SubsetView") products_view.setSelectionMode( QtWidgets.QAbstractItemView.ExtendedSelection ) + products_view.setAllColumnsShowFocus(True) + # TODO - add context menu + products_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + products_view.setSortingEnabled(True) + products_view.sortByColumn(1, QtCore.Qt.AscendingOrder) products_view.setAlternatingRowColors(True) products_model = ProductsModel(controller) From 3469a9b7be080f3c5706c899eac784a4a5858a3f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 22 Sep 2023 18:58:22 +0200 Subject: [PATCH 05/69] fix refresh of products --- .../tools/ayon_loader/ui/products_model.py | 246 +++++++++++++----- 1 file changed, 176 insertions(+), 70 deletions(-) diff --git a/openpype/tools/ayon_loader/ui/products_model.py b/openpype/tools/ayon_loader/ui/products_model.py index 78ff5e8b72b..13189178f53 100644 --- a/openpype/tools/ayon_loader/ui/products_model.py +++ b/openpype/tools/ayon_loader/ui/products_model.py @@ -5,8 +5,8 @@ PRODUCTS_MODEL_SENDER_NAME = "qt_products_model" -FOLDER_LABEL_ROLE = QtCore.Qt.UserRole + 1 -FOLDER_ID_ROLE = QtCore.Qt.UserRole + 2 +FOLDER_LABEL_ROLE = QtCore.Qt.UserRole + 2 +FOLDER_ID_ROLE = QtCore.Qt.UserRole + 3 PRODUCT_ID_ROLE = QtCore.Qt.UserRole + 5 PRODUCT_NAME_ROLE = QtCore.Qt.UserRole + 6 PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 7 @@ -106,8 +106,8 @@ def data(self, index, role=None): if role == QtCore.Qt.DisplayRole: if not index.data(PRODUCT_ID_ROLE): - pass - elif col == self.version_col: + return None + if col == self.version_col: role = VERSION_NAME_ROLE elif col == 1: role = PRODUCT_TYPE_ROLE @@ -172,26 +172,11 @@ def _clear(self): self._product_items_by_id = {} - def _remove_items(self, items): - root_item = self.invisibleRootItem() - for item in items: - if item is None: - continue - row = item.row() - if row < 0: - continue - parent = item.parent() - if parent is None: - parent = root_item - parent.takeRow(row) - def _get_group_model_item(self, group_name): - if group_name is None: - return self.invisibleRootItem() - model_item = self._group_items_by_name.get(group_name) if model_item is None: model_item = QtGui.QStandardItem(group_name) + model_item.setEditable(False) model_item.setColumnCount(self.columnCount()) self._group_items_by_name[group_name] = model_item return model_item @@ -200,6 +185,7 @@ def _get_merged_model_item(self, path): model_item = self._merged_items_by_id.get(path) if model_item is None: model_item = QtGui.QStandardItem(path) + model_item.setEditable(False) model_item.setColumnCount(self.columnCount()) self._merged_items_by_id[path] = model_item return model_item @@ -236,6 +222,7 @@ def _get_product_model_item(self, product_item): if model_item is None: product_id = product_item.product_id model_item = QtGui.QStandardItem(product_item.product_name) + model_item.setEditable(False) icon = get_qt_icon(product_item.product_icon) product_type_icon = get_qt_icon(product_item.product_type_icon) model_item.setColumnCount(self.columnCount()) @@ -253,6 +240,7 @@ def _get_product_model_item(self, product_item): return model_item def refresh(self, project_name, folder_ids): + self._clear() product_items = self._controller.get_product_items( project_name, folder_ids, @@ -262,20 +250,6 @@ def refresh(self, project_name, folder_ids): product_item.product_id: product_item for product_item in product_items } - # Remove product items that are not available - product_ids_to_remove = ( - set(self._items_by_id.keys()) - set(product_items_by_id.keys()) - ) - - items_to_remove = [ - self._items_by_id.pop(product_id) - for product_id in product_ids_to_remove - ] - ( - self._product_items_by_id.pop(product_id) - for product_id in product_ids_to_remove - ) - self._remove_items(items_to_remove) # Prepare product groups product_name_matches_by_group = collections.defaultdict(dict) @@ -292,17 +266,12 @@ def refresh(self, project_name, folder_ids): group[product_name].append(product_item) group_names = set(product_name_matches_by_group.keys()) - has_root_items = None in group_names - if has_root_items: - group_names.remove(None) - s_group_names = list(sorted(group_names)) - if has_root_items: - s_group_names.insert(0, None) root_item = self.invisibleRootItem() + new_root_items = [] merged_paths = set() - for group_name in s_group_names: - key_parts = ["M"] + for group_name in group_names: + key_parts = [] if group_name: key_parts.append(group_name) @@ -317,48 +286,185 @@ def refresh(self, project_name, folder_ids): merged_paths.add(path) merged_product_items[path] = product_items + parent_item = None + if group_name: + parent_item = self._get_group_model_item(group_name) + new_items = [] - parent_item = self._get_group_model_item(group_name) - c_parent_item = None - if parent_item is not root_item: - c_parent_item = parent_item + if parent_item is not None and parent_item.row() < 0: + new_root_items.append(parent_item) for product_item in top_items: item = self._get_product_model_item(product_item) - if ( - item.row() < 0 - or item.parent() is not c_parent_item - ): - new_items.append(item) + new_items.append(item) for path, product_items in merged_product_items.items(): merged_item = self._get_merged_model_item(path) - if merged_item.parent() is not parent_item: - new_items.append(merged_item) + new_items.append(merged_item) new_merged_items = [] for product_item in product_items: item = self._get_product_model_item(product_item) - if item.parent() is not merged_item: - new_merged_items.append(item) + new_merged_items.append(item) if new_merged_items: merged_item.appendRows(new_merged_items) - if new_items: + if not new_items: + continue + + if parent_item is None: + new_root_items.extend(new_items) + else: parent_item.appendRows(new_items) - merged_item_ids_to_remove = ( - set(self._merged_items_by_id.keys()) - merged_paths - ) - self._remove_items( - self._merged_items_by_id.pop(item_id) - for item_id in merged_item_ids_to_remove - ) - group_names_to_remove = ( - set(self._group_items_by_name.keys()) - set(s_group_names) - ) - self._remove_items( - self._group_items_by_name.pop(group_name) - for group_name in group_names_to_remove - ) + root_item.appendRows(new_root_items) + # --------------------------------- + # This implementation does not call '_clear' at the start + # but is more complex and probably slower + # --------------------------------- + # def _remove_items(self, items): + # if not items: + # return + # root_item = self.invisibleRootItem() + # for item in items: + # row = item.row() + # if row < 0: + # continue + # parent = item.parent() + # if parent is None: + # parent = root_item + # parent.removeRow(row) + # + # def _remove_group_items(self, group_names): + # group_items = [ + # self._group_items_by_name.pop(group_name) + # for group_name in group_names + # ] + # self._remove_items(group_items) + # + # def _remove_merged_items(self, paths): + # merged_items = [ + # self._merged_items_by_id.pop(path) + # for path in paths + # ] + # self._remove_items(merged_items) + # + # def _remove_product_items(self, product_ids): + # product_items = [] + # for product_id in product_ids: + # self._product_items_by_id.pop(product_id) + # product_items.append(self._items_by_id.pop(product_id)) + # self._remove_items(product_items) + # + # def _add_to_new_items(self, item, parent_item, new_items, root_item): + # if item.row() < 0: + # new_items.append(item) + # else: + # item_parent = item.parent() + # if item_parent is not parent_item: + # if item_parent is None: + # item_parent = root_item + # item_parent.takeRow(item.row()) + # new_items.append(item) + + # def refresh(self, project_name, folder_ids): + # product_items = self._controller.get_product_items( + # project_name, + # folder_ids, + # sender=PRODUCTS_MODEL_SENDER_NAME + # ) + # product_items_by_id = { + # product_item.product_id: product_item + # for product_item in product_items + # } + # # Remove product items that are not available + # product_ids_to_remove = ( + # set(self._items_by_id.keys()) - set(product_items_by_id.keys()) + # ) + # self._remove_product_items(product_ids_to_remove) + # + # # Prepare product groups + # product_name_matches_by_group = collections.defaultdict(dict) + # for product_item in product_items_by_id.values(): + # group_name = None + # if self._grouping_enabled: + # group_name = product_item.group_name + # + # product_name = product_item.product_name + # group = product_name_matches_by_group[group_name] + # if product_name not in group: + # group[product_name] = [product_item] + # continue + # group[product_name].append(product_item) + # + # group_names = set(product_name_matches_by_group.keys()) + # + # root_item = self.invisibleRootItem() + # new_root_items = [] + # merged_paths = set() + # for group_name in group_names: + # key_parts = [] + # if group_name: + # key_parts.append(group_name) + # + # groups = product_name_matches_by_group[group_name] + # merged_product_items = {} + # top_items = [] + # for product_name, product_items in groups.items(): + # if len(product_items) == 1: + # top_items.append(product_items[0]) + # else: + # path = "/".join(key_parts + [product_name]) + # merged_paths.add(path) + # merged_product_items[path] = product_items + # + # parent_item = None + # if group_name: + # parent_item = self._get_group_model_item(group_name) + # + # new_items = [] + # if parent_item is not None and parent_item.row() < 0: + # new_root_items.append(parent_item) + # + # for product_item in top_items: + # item = self._get_product_model_item(product_item) + # self._add_to_new_items( + # item, parent_item, new_items, root_item + # ) + # + # for path, product_items in merged_product_items.items(): + # merged_item = self._get_merged_model_item(path) + # self._add_to_new_items( + # merged_item, parent_item, new_items, root_item + # ) + # + # new_merged_items = [] + # for product_item in product_items: + # item = self._get_product_model_item(product_item) + # self._add_to_new_items( + # item, merged_item, new_merged_items, root_item + # ) + # + # if new_merged_items: + # merged_item.appendRows(new_merged_items) + # + # if not new_items: + # continue + # + # if parent_item is not None: + # parent_item.appendRows(new_items) + # continue + # + # new_root_items.extend(new_items) + # + # root_item.appendRows(new_root_items) + # + # merged_item_ids_to_remove = ( + # set(self._merged_items_by_id.keys()) - merged_paths + # ) + # group_names_to_remove = ( + # set(self._group_items_by_name.keys()) - set(group_names) + # ) + # self._remove_merged_items(merged_item_ids_to_remove) + # self._remove_group_items(group_names_to_remove) From 3dbb9eb9f4f4389b83dcac18cc2dbfd7ba000165 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 22 Sep 2023 19:06:32 +0200 Subject: [PATCH 06/69] added enable grouping checkbox --- .../tools/ayon_loader/ui/products_model.py | 17 ++++++++++++++- .../tools/ayon_loader/ui/products_widget.py | 3 +++ openpype/tools/ayon_loader/ui/window.py | 21 +++++++++++++++++-- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/openpype/tools/ayon_loader/ui/products_model.py b/openpype/tools/ayon_loader/ui/products_model.py index 13189178f53..4a07929c159 100644 --- a/openpype/tools/ayon_loader/ui/products_model.py +++ b/openpype/tools/ayon_loader/ui/products_model.py @@ -61,6 +61,9 @@ def __init__(self, controller): self._product_items_by_id = {} self._grouping_enabled = False + self._last_project_name = None + self._last_folder_ids = [] + def flags(self, index): # Make the version column editable if index.column() == self.version_col and index.data(PRODUCT_ID_ROLE): @@ -169,7 +172,6 @@ def _clear(self): self._items_by_id = {} self._group_items_by_name = {} self._merged_items_by_id = {} - self._product_items_by_id = {} def _get_group_model_item(self, group_name): @@ -239,8 +241,21 @@ def _get_product_model_item(self, product_item): self._set_version_data_to_product_item(model_item, last_version) return model_item + def set_enable_grouping(self, enable_grouping): + if enable_grouping is self._grouping_enabled: + return + self._grouping_enabled = enable_grouping + # Ignore change if groups are not available + if not self._group_items_by_name: + return + self.refresh(self._last_project_name, self._last_folder_ids) + def refresh(self, project_name, folder_ids): self._clear() + + self._last_project_name = project_name + self._last_folder_ids = folder_ids + product_items = self._controller.get_product_items( project_name, folder_ids, diff --git a/openpype/tools/ayon_loader/ui/products_widget.py b/openpype/tools/ayon_loader/ui/products_widget.py index f7c0cf44d4b..b4efe813a0d 100644 --- a/openpype/tools/ayon_loader/ui/products_widget.py +++ b/openpype/tools/ayon_loader/ui/products_widget.py @@ -137,6 +137,9 @@ def set_product_type_filter(self, product_type_filters): product_type_filters ) + def set_enable_grouping(self, enable_grouping): + self._products_model.set_enable_grouping(enable_grouping) + def _refresh_model(self): self._products_model.refresh( self._selected_project_name, diff --git a/openpype/tools/ayon_loader/ui/window.py b/openpype/tools/ayon_loader/ui/window.py index bb13ff773db..a569bdf9fb1 100644 --- a/openpype/tools/ayon_loader/ui/window.py +++ b/openpype/tools/ayon_loader/ui/window.py @@ -48,14 +48,23 @@ def __init__(self, controller=None, parent=None): # Subset + version selection item products_wrap_widget = QtWidgets.QWidget(main_splitter) - products_filter_input = PlaceholderLineEdit(products_wrap_widget) + products_inputs_widget = QtWidgets.QWidget(products_wrap_widget) + + products_filter_input = PlaceholderLineEdit(products_inputs_widget) products_filter_input.setPlaceholderText("Product name filter...") + product_group_checkbox = QtWidgets.QCheckBox( + "Enable grouping", products_inputs_widget) products_widget = ProductsWidget(controller, products_wrap_widget) + products_inputs_layout = QtWidgets.QHBoxLayout(products_inputs_widget) + products_inputs_layout.setContentsMargins(0, 0, 0, 0) + products_inputs_layout.addWidget(products_filter_input, 1) + products_inputs_layout.addWidget(product_group_checkbox, 0) + products_wrap_layout = QtWidgets.QVBoxLayout(products_wrap_widget) products_wrap_layout.setContentsMargins(0, 0, 0, 0) - products_wrap_layout.addWidget(products_filter_input, 0) + products_wrap_layout.addWidget(products_inputs_widget, 0) products_wrap_layout.addWidget(products_widget, 1) main_splitter.addWidget(context_widget) @@ -78,6 +87,8 @@ def __init__(self, controller=None, parent=None): product_types_widget.filter_changed.connect( self._on_product_type_filter_change ) + product_group_checkbox.stateChanged.connect( + self._on_product_group_change) self._projects_combobox = projects_combobox @@ -87,6 +98,7 @@ def __init__(self, controller=None, parent=None): self._product_types_widget = product_types_widget self._products_filter_input = products_filter_input + self._product_group_checkbox = product_group_checkbox self._products_widget = products_widget self._controller = controller @@ -128,6 +140,11 @@ def _on_show_timer(self): def _on_folder_filete_change(self, text): self._folders_widget.set_name_filer(text) + def _on_product_group_change(self): + self._products_widget.set_enable_grouping( + self._product_group_checkbox.isChecked() + ) + def _on_product_type_filter_change(self): self._products_widget.set_product_type_filter( self._product_types_widget.get_filter_info() From f8c1686ddf31c79fadb2887b8614feb8b993697a Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 25 Sep 2023 12:36:02 +0200 Subject: [PATCH 07/69] added icons and sorting of grouped items --- .../tools/ayon_loader/ui/products_model.py | 56 +++++++++++++++++-- .../tools/ayon_loader/ui/products_widget.py | 40 ++++++++++++- openpype/tools/ayon_loader/ui/window.py | 1 + 3 files changed, 90 insertions(+), 7 deletions(-) diff --git a/openpype/tools/ayon_loader/ui/products_model.py b/openpype/tools/ayon_loader/ui/products_model.py index 4a07929c159..a42963e7a34 100644 --- a/openpype/tools/ayon_loader/ui/products_model.py +++ b/openpype/tools/ayon_loader/ui/products_model.py @@ -1,10 +1,13 @@ import collections + +import qtawesome from qtpy import QtGui, QtCore from openpype.tools.ayon_utils.widgets import get_qt_icon PRODUCTS_MODEL_SENDER_NAME = "qt_products_model" +GROUP_TYPE_ROLE = QtCore.Qt.UserRole + 1 FOLDER_LABEL_ROLE = QtCore.Qt.UserRole + 2 FOLDER_ID_ROLE = QtCore.Qt.UserRole + 3 PRODUCT_ID_ROLE = QtCore.Qt.UserRole + 5 @@ -40,6 +43,21 @@ class ProductsModel(QtGui.QStandardItemModel): "In scene", "Availability", ] + merged_items_colors = [ + ("#{0:02x}{1:02x}{2:02x}".format(*c), QtGui.QColor(*c)) + for c in [ + (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 + ] + ] version_col = column_labels.index("Version") published_time_col = column_labels.index("Time") @@ -60,6 +78,7 @@ def __init__(self, controller): # product item objects (they have version information) self._product_items_by_id = {} self._grouping_enabled = False + self._reset_merge_color = False self._last_project_name = None self._last_folder_ids = [] @@ -102,7 +121,7 @@ def data(self, index, role=None): product_item = self._product_items_by_id.get(product_id) if product_item is None: return None - return product_item.versions + return product_item.version_items if role == QtCore.Qt.EditRole: return None @@ -165,6 +184,17 @@ def setData(self, index, value, role=None): return True return super(ProductsModel, self).setData(index, value, role) + def _get_next_color(self): + return next(self._color_iter()) + + def _color_iter(self): + while True: + for color in self.merged_items_colors: + if self._reset_merge_color: + self._reset_merge_color = False + break + yield color + def _clear(self): root_item = self.invisibleRootItem() root_item.removeRows(0, root_item.rowCount()) @@ -173,23 +203,28 @@ def _clear(self): self._group_items_by_name = {} self._merged_items_by_id = {} self._product_items_by_id = {} + self._reset_merge_color = True def _get_group_model_item(self, group_name): model_item = self._group_items_by_name.get(group_name) if model_item is None: model_item = QtGui.QStandardItem(group_name) + model_item.setData(0, GROUP_TYPE_ROLE) model_item.setEditable(False) model_item.setColumnCount(self.columnCount()) self._group_items_by_name[group_name] = model_item return model_item - def _get_merged_model_item(self, path): + def _get_merged_model_item(self, path, count): model_item = self._merged_items_by_id.get(path) if model_item is None: - model_item = QtGui.QStandardItem(path) + model_item = QtGui.QStandardItem() + model_item.setData(1, GROUP_TYPE_ROLE) model_item.setEditable(False) model_item.setColumnCount(self.columnCount()) self._merged_items_by_id[path] = model_item + label = "{} ({})".format(path, count) + model_item.setData(label, QtCore.Qt.DisplayRole) return model_item def _set_version_data_to_product_item(self, model_item, version_item): @@ -293,7 +328,9 @@ def refresh(self, project_name, folder_ids): groups = product_name_matches_by_group[group_name] merged_product_items = {} top_items = [] + group_product_types = set() for product_name, product_items in groups.items(): + group_product_types |= {p.product_type for p in product_items} if len(product_items) == 1: top_items.append(product_items[0]) else: @@ -304,6 +341,8 @@ def refresh(self, project_name, folder_ids): parent_item = None if group_name: parent_item = self._get_group_model_item(group_name) + parent_item.setData( + "|".join(group_product_types), PRODUCT_TYPE_ROLE) new_items = [] if parent_item is not None and parent_item.row() < 0: @@ -314,14 +353,23 @@ def refresh(self, project_name, folder_ids): new_items.append(item) for path, product_items in merged_product_items.items(): - merged_item = self._get_merged_model_item(path) + (merged_color_hex, merged_color_qt) = self._get_next_color() + merged_color = qtawesome.icon( + "fa.circle", color=merged_color_qt) + merged_item = self._get_merged_model_item( + path, len(product_items)) + merged_item.setData(merged_color, QtCore.Qt.DecorationRole) new_items.append(merged_item) + merged_product_types = set() new_merged_items = [] for product_item in product_items: item = self._get_product_model_item(product_item) new_merged_items.append(item) + merged_product_types.add(product_item.product_type) + merged_item.setData( + "|".join(merged_product_types), PRODUCT_TYPE_ROLE) if new_merged_items: merged_item.appendRows(new_merged_items) diff --git a/openpype/tools/ayon_loader/ui/products_widget.py b/openpype/tools/ayon_loader/ui/products_widget.py index b4efe813a0d..c60ce8ce4a3 100644 --- a/openpype/tools/ayon_loader/ui/products_widget.py +++ b/openpype/tools/ayon_loader/ui/products_widget.py @@ -10,6 +10,7 @@ ProductsModel, PRODUCTS_MODEL_SENDER_NAME, PRODUCT_TYPE_ROLE, + GROUP_TYPE_ROLE, ) from .products_delegates import VersionDelegate @@ -19,6 +20,7 @@ def __init__(self, parent=None): super(ProductsProxyModel, self).__init__(parent) self._product_type_filters = {} + self._ascending_sort = True def set_product_type_filters(self, product_type_filters): self._product_type_filters = product_type_filters @@ -27,12 +29,43 @@ def set_product_type_filters(self, product_type_filters): def filterAcceptsRow(self, source_row, source_parent): source_model = self.sourceModel() index = source_model.index(source_row, 0, source_parent) - product_type = source_model.data(index, PRODUCT_TYPE_ROLE) - if not self._product_type_filters.get(product_type, True): - return False + product_types_s = source_model.data(index, PRODUCT_TYPE_ROLE) + product_types = [] + if product_types_s: + product_types = product_types_s.split("|") + + for product_type in product_types: + if not self._product_type_filters.get(product_type, True): + return False return super(ProductsProxyModel, self).filterAcceptsRow( source_row, source_parent) + def lessThan(self, left, right): + l_model = left.model() + r_model = right.model() + left_group_type = l_model.data(left, GROUP_TYPE_ROLE) + right_group_type = r_model.data(right, GROUP_TYPE_ROLE) + # Groups are always on top, merged product types are below + # and items without group at the bottom + # QUESTION Do we need to do it this way? + if left_group_type != right_group_type: + if left_group_type is None: + output = False + elif right_group_type is None: + output = True + else: + output = left_group_type < right_group_type + if not self._ascending_sort: + output = not output + return output + return super(ProductsProxyModel, self).lessThan(left, right) + + def sort(self, column, order=None): + if order is None: + order = QtCore.Qt.AscendingOrder + self._ascending_sort = order == QtCore.Qt.AscendingOrder + super(ProductsProxyModel, self).sort(column, order) + class ProductsWidget(QtWidgets.QWidget): default_widths = ( @@ -65,6 +98,7 @@ def __init__(self, controller, parent): # TODO - add context menu products_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) products_view.setSortingEnabled(True) + # Sort by product type products_view.sortByColumn(1, QtCore.Qt.AscendingOrder) products_view.setAlternatingRowColors(True) diff --git a/openpype/tools/ayon_loader/ui/window.py b/openpype/tools/ayon_loader/ui/window.py index a569bdf9fb1..2d46df48480 100644 --- a/openpype/tools/ayon_loader/ui/window.py +++ b/openpype/tools/ayon_loader/ui/window.py @@ -54,6 +54,7 @@ def __init__(self, controller=None, parent=None): products_filter_input.setPlaceholderText("Product name filter...") product_group_checkbox = QtWidgets.QCheckBox( "Enable grouping", products_inputs_widget) + product_group_checkbox.setChecked(True) products_widget = ProductsWidget(controller, products_wrap_widget) From f0f71775a231aeef89acfffe14c79c53d384af27 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 25 Sep 2023 17:26:59 +0200 Subject: [PATCH 08/69] fix version delegate --- .../ayon_loader/ui/products_delegates.py | 27 +++++----- .../tools/ayon_loader/ui/products_model.py | 43 +++++++++++----- .../tools/ayon_loader/ui/products_widget.py | 51 +++++++++++++++++-- 3 files changed, 89 insertions(+), 32 deletions(-) diff --git a/openpype/tools/ayon_loader/ui/products_delegates.py b/openpype/tools/ayon_loader/ui/products_delegates.py index 955544417f7..0dcdc96aab6 100644 --- a/openpype/tools/ayon_loader/ui/products_delegates.py +++ b/openpype/tools/ayon_loader/ui/products_delegates.py @@ -14,9 +14,9 @@ class VersionComboBox(QtWidgets.QComboBox): value_changed = QtCore.Signal(str) - def __init__(self, subset_id, parent): + def __init__(self, product_id, parent): super(VersionComboBox, self).__init__(parent) - self._subset_id = subset_id + self._product_id = product_id self._items_by_id = {} self._current_id = None @@ -46,7 +46,7 @@ def update_versions(self, version_items, current_version_id): item = self._items_by_id.get(version_id) if item is None: label = format_version( - version_item.version, version_item.is_hero + abs(version_item.version), version_item.is_hero ) item = QtGui.QStandardItem(label) item.setData(version_id, QtCore.Qt.UserRole) @@ -65,25 +65,22 @@ def _on_index_change(self): if value == self._current_id: return self._current_id = value - self.value_changed.emit(self._subset_id) + self.value_changed.emit(self._product_id) class VersionDelegate(QtWidgets.QStyledItemDelegate): """A delegate that display version integer formatted as version string.""" version_changed = QtCore.Signal() - first_run = False def __init__(self, *args, **kwargs): super(VersionDelegate, self).__init__(*args, **kwargs) - self._editor_by_subset_id = {} + self._editor_by_product_id = {} def displayText(self, value, locale): - if isinstance(value, HeroVersionType): - return format_version(value, True) if not isinstance(value, numbers.Integral): return "N/A" - return format_version(value) + return format_version(abs(value), value < 0) def paint(self, painter, option, index): fg_color = index.data(QtCore.Qt.ForegroundRole) @@ -130,18 +127,18 @@ def paint(self, painter, option, index): painter.restore() def createEditor(self, parent, option, index): - subset_id = index.data(PRODUCT_ID_ROLE) - if not subset_id: + product_id = index.data(PRODUCT_ID_ROLE) + if not product_id: return - editor = VersionComboBox(subset_id, parent) - self._editor_by_subset_id[subset_id] = editor + editor = VersionComboBox(product_id, parent) + self._editor_by_product_id[product_id] = editor editor.value_changed.connect(self._on_editor_change) return editor - def _on_editor_change(self, subset_id): - editor = self._editor_by_subset_id[subset_id] + def _on_editor_change(self, product_id): + editor = self._editor_by_product_id[product_id] # Update model data self.commitData.emit(editor) diff --git a/openpype/tools/ayon_loader/ui/products_model.py b/openpype/tools/ayon_loader/ui/products_model.py index a42963e7a34..ed542bf902b 100644 --- a/openpype/tools/ayon_loader/ui/products_model.py +++ b/openpype/tools/ayon_loader/ui/products_model.py @@ -29,6 +29,7 @@ class ProductsModel(QtGui.QStandardItemModel): + refreshed = QtCore.Signal() column_labels = [ "Product name", "Product type", @@ -83,6 +84,21 @@ def __init__(self, controller): self._last_project_name = None self._last_folder_ids = [] + def get_product_item_indexes(self): + return [ + item.index() + for item in self._items_by_id.values() + ] + + def set_enable_grouping(self, enable_grouping): + if enable_grouping is self._grouping_enabled: + return + self._grouping_enabled = enable_grouping + # Ignore change if groups are not available + if not self._group_items_by_name: + return + self.refresh(self._last_project_name, self._last_folder_ids) + def flags(self, index): # Make the version column editable if index.column() == self.version_col and index.data(PRODUCT_ID_ROLE): @@ -174,13 +190,18 @@ def setData(self, index, value, role=None): index = self.index(index.row(), 0, index.parent()) product_id = index.data(PRODUCT_ID_ROLE) product_item = self._product_items_by_id[product_id] - version_item = product_item.get_version_by_id(value) - if version_item is None: + final_version_item = None + for version_item in product_item.version_items: + if version_item.version_id == value: + final_version_item = version_item + break + + if final_version_item is None: return False - if index.data(VERSION_ID_ROLE) == version_item.version_id: + if index.data(VERSION_ID_ROLE) == final_version_item.version_id: return True item = self.itemFromIndex(index) - self._set_version_data_to_product_item(item, version_item) + self._set_version_data_to_product_item(item, final_version_item) return True return super(ProductsModel, self).setData(index, value, role) @@ -276,15 +297,6 @@ def _get_product_model_item(self, product_item): self._set_version_data_to_product_item(model_item, last_version) return model_item - def set_enable_grouping(self, enable_grouping): - if enable_grouping is self._grouping_enabled: - return - self._grouping_enabled = enable_grouping - # Ignore change if groups are not available - if not self._group_items_by_name: - return - self.refresh(self._last_project_name, self._last_folder_ids) - def refresh(self, project_name, folder_ids): self._clear() @@ -381,7 +393,10 @@ def refresh(self, project_name, folder_ids): else: parent_item.appendRows(new_items) - root_item.appendRows(new_root_items) + if new_root_items: + root_item.appendRows(new_root_items) + + self.refreshed.emit() # --------------------------------- # This implementation does not call '_clear' at the start # but is more complex and probably slower diff --git a/openpype/tools/ayon_loader/ui/products_widget.py b/openpype/tools/ayon_loader/ui/products_widget.py index c60ce8ce4a3..d5390233c05 100644 --- a/openpype/tools/ayon_loader/ui/products_widget.py +++ b/openpype/tools/ayon_loader/ui/products_widget.py @@ -1,3 +1,4 @@ +import collections from qtpy import QtWidgets, QtCore from openpype.tools.utils.delegates import PrettyTimeDelegate @@ -11,6 +12,7 @@ PRODUCTS_MODEL_SENDER_NAME, PRODUCT_TYPE_ROLE, GROUP_TYPE_ROLE, + PRODUCT_ID_ROLE, ) from .products_delegates import VersionDelegate @@ -111,9 +113,9 @@ def __init__(self, controller, parent): for idx, width in enumerate(self.default_widths): products_view.setColumnWidth(idx, width) - # version_delegate = VersionDelegate() - # products_view.setItemDelegateForColumn( - # products_model.version_col, version_delegate) + version_delegate = VersionDelegate() + products_view.setItemDelegateForColumn( + products_model.version_col, version_delegate) time_delegate = PrettyTimeDelegate() products_view.setItemDelegateForColumn( @@ -123,6 +125,11 @@ def __init__(self, controller, parent): main_layout.setContentsMargins(0, 0, 0, 0) main_layout.addWidget(products_view, 1) + products_proxy_model.rowsInserted.connect(self._on_rows_inserted) + products_proxy_model.rowsMoved.connect(self._on_rows_moved) + products_proxy_model.rowsRemoved.connect(self._on_rows_removed) + products_model.refreshed.connect(self._on_refresh) + controller.register_event_callback( "selection.folders.changed", self._on_folders_selection_change, @@ -144,6 +151,9 @@ def __init__(self, controller, parent): self._products_model = products_model self._products_proxy_model = products_proxy_model + self._version_delegate = version_delegate + self._time_delegate = time_delegate + self._selected_project_name = None self._selected_folder_ids = set() @@ -174,6 +184,41 @@ def set_product_type_filter(self, product_type_filters): def set_enable_grouping(self, enable_grouping): self._products_model.set_enable_grouping(enable_grouping) + def _fill_version_editor(self): + model = self._products_proxy_model + index_queue = collections.deque() + for row in range(model.rowCount()): + index_queue.append((row, None)) + + version_col = self._products_model.version_col + while index_queue: + (row, parent_index) = index_queue.popleft() + args = [row, 0] + if parent_index is not None: + args.append(parent_index) + index = model.index(*args) + rows = model.rowCount(index) + for row in range(rows): + index_queue.append((row, index)) + + product_id = model.data(index, PRODUCT_ID_ROLE) + if product_id is not None: + args[1] = version_col + v_index = model.index(*args) + self._products_view.openPersistentEditor(v_index) + + def _on_refresh(self): + self._fill_version_editor() + + def _on_rows_inserted(self): + self._fill_version_editor() + + def _on_rows_moved(self): + self._fill_version_editor() + + def _on_rows_removed(self): + self._fill_version_editor() + def _refresh_model(self): self._products_model.refresh( self._selected_project_name, From 12f1c598717e63269a540cbd1130bcf84da41ab3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 25 Sep 2023 17:31:15 +0200 Subject: [PATCH 09/69] add splitter between context and product type filtering --- openpype/tools/ayon_loader/ui/window.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/openpype/tools/ayon_loader/ui/window.py b/openpype/tools/ayon_loader/ui/window.py index 2d46df48480..8f70c31428a 100644 --- a/openpype/tools/ayon_loader/ui/window.py +++ b/openpype/tools/ayon_loader/ui/window.py @@ -25,8 +25,12 @@ def __init__(self, controller=None, parent=None): controller = LoaderController() main_splitter = QtWidgets.QSplitter(self) + + context_splitter = QtWidgets.QSplitter(main_splitter) + context_splitter.setOrientation(QtCore.Qt.Vertical) + # Context selection widget - context_widget = QtWidgets.QWidget(main_splitter) + context_widget = QtWidgets.QWidget(context_splitter) projects_combobox = ProjectsCombobox(controller, context_widget) projects_combobox.set_select_item_visible(True) @@ -36,14 +40,18 @@ def __init__(self, controller=None, parent=None): folders_widget = LoaderFoldersWidget(controller, context_widget) - product_types_widget = ProductTypesView(controller, context_widget) + product_types_widget = ProductTypesView(controller, context_splitter) context_layout = QtWidgets.QVBoxLayout(context_widget) context_layout.setContentsMargins(0, 0, 0, 0) context_layout.addWidget(projects_combobox, 0) context_layout.addWidget(folders_filter_input, 0) context_layout.addWidget(folders_widget, 1) - context_layout.addWidget(product_types_widget, 1) + + context_splitter.addWidget(context_widget) + context_splitter.addWidget(product_types_widget) + context_splitter.setStretchFactor(0, 65) + context_splitter.setStretchFactor(1, 35) # Subset + version selection item products_wrap_widget = QtWidgets.QWidget(main_splitter) @@ -68,7 +76,7 @@ def __init__(self, controller=None, parent=None): products_wrap_layout.addWidget(products_inputs_widget, 0) products_wrap_layout.addWidget(products_widget, 1) - main_splitter.addWidget(context_widget) + main_splitter.addWidget(context_splitter) main_splitter.addWidget(products_wrap_widget) main_splitter.setStretchFactor(0, 3) From 75414f65e1078c682e278eb5c4911f6337a618f0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 25 Sep 2023 17:34:30 +0200 Subject: [PATCH 10/69] fix products filtering by name --- openpype/tools/ayon_loader/ui/window.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/openpype/tools/ayon_loader/ui/window.py b/openpype/tools/ayon_loader/ui/window.py index 8f70c31428a..b588502274a 100644 --- a/openpype/tools/ayon_loader/ui/window.py +++ b/openpype/tools/ayon_loader/ui/window.py @@ -91,11 +91,14 @@ def __init__(self, controller=None, parent=None): show_timer.timeout.connect(self._on_show_timer) folders_filter_input.textChanged.connect( - self._on_folder_filete_change + self._on_folder_filter_change ) product_types_widget.filter_changed.connect( self._on_product_type_filter_change ) + products_filter_input.textChanged.connect( + self._on_product_filter_change + ) product_group_checkbox.stateChanged.connect( self._on_product_group_change) @@ -146,7 +149,7 @@ def _on_show_timer(self): self._reset_on_show = False self._controller.reset() - def _on_folder_filete_change(self, text): + def _on_folder_filter_change(self, text): self._folders_widget.set_name_filer(text) def _on_product_group_change(self): @@ -154,6 +157,9 @@ def _on_product_group_change(self): self._product_group_checkbox.isChecked() ) + def _on_product_filter_change(self, text): + self._products_widget.set_name_filer(text) + def _on_product_type_filter_change(self): self._products_widget.set_product_type_filter( self._product_types_widget.get_filter_info() From a844a442c8ed24d1c67b4d8935cfcb0bd67133db Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 27 Sep 2023 11:38:50 +0200 Subject: [PATCH 11/69] implemented 'filter_repre_contexts_by_loader' --- openpype/pipeline/load/__init__.py | 2 ++ openpype/pipeline/load/utils.py | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/openpype/pipeline/load/__init__.py b/openpype/pipeline/load/__init__.py index 7320a9f0e81..ca11b262110 100644 --- a/openpype/pipeline/load/__init__.py +++ b/openpype/pipeline/load/__init__.py @@ -32,6 +32,7 @@ loaders_from_repre_context, loaders_from_representation, + filter_repre_contexts_by_loader, any_outdated_containers, get_outdated_containers, @@ -85,6 +86,7 @@ "loaders_from_repre_context", "loaders_from_representation", + "filter_repre_contexts_by_loader", "any_outdated_containers", "get_outdated_containers", diff --git a/openpype/pipeline/load/utils.py b/openpype/pipeline/load/utils.py index b10d6032b36..c81aeff6bdb 100644 --- a/openpype/pipeline/load/utils.py +++ b/openpype/pipeline/load/utils.py @@ -790,6 +790,24 @@ def loaders_from_repre_context(loaders, repre_context): ] +def filter_repre_contexts_by_loader(repre_contexts, loader): + """Filter representation contexts for loader. + + Args: + repre_contexts (list[dict[str, Ant]]): Representation context. + loader (LoaderPlugin): Loader plugin to filter contexts for. + + Returns: + list[dict[str, Any]]: Filtered representation contexts. + """ + + return [ + repre_context + for repre_context in repre_contexts + if is_compatible_loader(loader, repre_context) + ] + + def loaders_from_representation(loaders, representation): """Return all compatible loaders for a representation.""" From 6cd61314fc3d08b0bff19dc2d262a6db092f2783 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 27 Sep 2023 11:39:26 +0200 Subject: [PATCH 12/69] implemented base of action items --- openpype/tools/ayon_loader/control.py | 32 +- openpype/tools/ayon_loader/models/__init__.py | 2 + openpype/tools/ayon_loader/models/actions.py | 531 ++++++++++++++++++ .../tools/ayon_loader/ui/products_model.py | 3 + .../tools/ayon_loader/ui/products_widget.py | 154 ++++- 5 files changed, 715 insertions(+), 7 deletions(-) create mode 100644 openpype/tools/ayon_loader/models/actions.py diff --git a/openpype/tools/ayon_loader/control.py b/openpype/tools/ayon_loader/control.py index 4952bdc6c30..557dea8e137 100644 --- a/openpype/tools/ayon_loader/control.py +++ b/openpype/tools/ayon_loader/control.py @@ -5,7 +5,7 @@ from openpype.tools.ayon_utils.models import ProjectsModel, HierarchyModel from .abstract import AbstractController -from .models import SelectionModel, ProductsModel +from .models import SelectionModel, ProductsModel, LoaderActionsModel class LoaderController(AbstractController): @@ -17,6 +17,7 @@ def __init__(self): self._projects_model = ProjectsModel(self) self._hierarchy_model = HierarchyModel(self) self._products_model = ProductsModel(self) + self._loader_actions_model = LoaderActionsModel(self) @property def log(self): @@ -65,6 +66,35 @@ def get_product_type_items(self, project_name): def get_folder_entity(self, project_name, folder_id): self._hierarchy_model.get_folder_entity(project_name, folder_id) + def get_versions_action_items(self, project_name, version_ids): + return self._loader_actions_model.get_versions_action_items( + project_name, version_ids) + + def get_representations_action_items( + self, project_name, representation_ids): + return self._loader_actions_model.get_representations_action_items( + project_name, representation_ids) + + def trigger_action_item( + self, + identifier, + options, + project_name, + folder_ids, + product_ids, + version_ids, + representation_ids, + ): + self._loader_actions_model.trigger_action_item( + identifier, + options, + project_name, + folder_ids, + product_ids, + version_ids, + representation_ids, + ) + # Selection model wrappers def get_selected_project_name(self): return self._selection_model.get_selected_project_name() diff --git a/openpype/tools/ayon_loader/models/__init__.py b/openpype/tools/ayon_loader/models/__init__.py index b7ab3865651..6adfe71d86e 100644 --- a/openpype/tools/ayon_loader/models/__init__.py +++ b/openpype/tools/ayon_loader/models/__init__.py @@ -1,8 +1,10 @@ from .selection import SelectionModel from .products import ProductsModel +from .actions import LoaderActionsModel __all__ = ( "SelectionModel", "ProductsModel", + "LoaderActionsModel", ) diff --git a/openpype/tools/ayon_loader/models/actions.py b/openpype/tools/ayon_loader/models/actions.py new file mode 100644 index 00000000000..e5bac3700c1 --- /dev/null +++ b/openpype/tools/ayon_loader/models/actions.py @@ -0,0 +1,531 @@ +import inspect +import copy +import collections +from abc import ABCMeta, abstractmethod + +import six + +from openpype.client import ( + get_project, + get_assets, + get_subsets, + get_versions, + get_representations, +) +from openpype.pipeline.load import ( + discover_loader_plugins, + SubsetLoaderPlugin, + filter_repre_contexts_by_loader, + get_loader_identifier, + load_with_repre_context, + load_with_subset_context, + load_with_subset_contexts, + LoadError, + IncompatibleLoaderError, +) +from openpype.tools.ayon_utils.models import NestedCacheItem + + +@six.add_metaclass(ABCMeta) +class BaseActionItem(object): + @abstractmethod + def item_type(self): + pass + + @abstractmethod + def to_data(self): + pass + + @classmethod + @abstractmethod + def from_data(cls, data): + pass + + +class ActionItem(BaseActionItem): + def __init__( + self, + identifier, + label, + icon, + tooltip, + options, + order, + project_name, + folder_ids, + product_ids, + version_ids, + representation_ids, + ): + self.identifier = identifier + self.label = label + self.icon = icon + self.tooltip = tooltip + self.options = options + self.order = order + self.project_name = project_name + self.folder_ids = folder_ids + self.product_ids = product_ids + self.version_ids = version_ids + self.representation_ids = representation_ids + + def item_type(self): + return "action" + + def to_data(self): + return { + "item_type": self.item_type(), + "identifier": self.identifier, + "label": self.label, + "icon": self.icon, + "tooltip": self.tooltip, + "options": self.options, + "order": self.order, + "project_name": self.project_name, + "folder_ids": self.folder_ids, + "product_ids": self.product_ids, + "version_ids": self.version_ids, + "representation_ids": self.representation_ids, + } + + @classmethod + def from_data(cls, data): + new_data = copy.deepcopy(data) + new_data.pop("item_type") + return cls(**new_data) + + +# NOTE This is just an idea. Not implemented on front end, +# also hits issues with sorting of items in the UI. +# class SeparatorItem(BaseActionItem): +# def item_type(self): +# return "separator" +# +# def to_data(self): +# return {"item_type": self.item_type()} +# +# @classmethod +# def from_data(cls, data): +# return cls() +# +# +# class MenuItem(BaseActionItem): +# def __init__(self, label, icon, children): +# self.label = label +# self.icon = icon +# self.children = children +# +# def item_type(self): +# return "menu" +# +# def to_data(self): +# return { +# "item_type": self.item_type(), +# "label": self.label, +# "icon": self.icon, +# "children": [child.to_data() for child in self.children] +# } +# +# @classmethod +# def from_data(cls, data): +# new_data = copy.deepcopy(data) +# new_data.pop("item_type") +# children = [] +# for child in data["children"]: +# child_type = child["item_type"] +# if child_type == "separator": +# children.append(SeparatorItem.from_data(child)) +# elif child_type == "menu": +# children.append(MenuItem.from_data(child)) +# elif child_type == "action": +# children.append(ActionItem.from_data(child)) +# else: +# raise ValueError("Invalid child type: {}".format(child_type)) +# +# new_data["children"] = children +# return cls(**new_data) + + +class LoaderActionsModel: + """Model for loader actions. + + This is probably only part of models that requires to use codebase from + 'openpype.client' because of backwards compatibility with loaders logic + which are expecting mongo documents. + + TODOs: + Use controller to get entities (documents) -> possible only when + loaders are able to handle AYON vs. OpenPype logic. + Cache loaders for time period and reset them on controller refresh. + Also cache them per project. + Add missing site sync logic, and if possible remove it from loaders. + Implement loader actions to replace load plugins. + Ask loader actions to return action items instead of guessing them. + """ + + # Cache loader plugins for some time + # NOTE Set to '0' for development + loaders_cache_lifetime = 30 + + def __init__(self, controller): + self._controller = controller + self._tool_name = "" + self._loaders_by_identifier = NestedCacheItem( + levels=1, lifetime=self.loaders_cache_lifetime) + self._product_loaders = NestedCacheItem( + levels=1, lifetime=self.loaders_cache_lifetime) + self._repre_loaders = NestedCacheItem( + levels=1, lifetime=self.loaders_cache_lifetime) + + def reset(self): + self._loaders_by_identifier.reset() + self._product_loaders.reset() + self._repre_loaders.reset() + + def get_versions_action_items(self, project_name, version_ids): + action_items = [] + if not project_name or not version_ids: + return action_items + + product_loaders, repre_loaders = self._get_loaders(project_name) + ( + product_context_by_id, + repre_context_by_id + ) = self._contexts_for_versions(project_name, version_ids) + + repre_contexts = list(repre_context_by_id.values()) + repre_ids = set(repre_context_by_id.keys()) + repre_version_ids = set() + repre_product_ids = set() + repre_folder_ids = set() + for repre_context in repre_context_by_id.values(): + repre_product_ids.add(repre_context["subset"]["_id"]) + repre_version_ids.add(repre_context["version"]["_id"]) + repre_folder_ids.add(repre_context["asset"]["_id"]) + + action_items = [] + for loader in repre_loaders: + if not repre_contexts: + break + + # # do not allow download whole repre, select specific repre + # if tools_lib.is_sync_loader(loader): + # continue + + filtered_repre_contexts = filter_repre_contexts_by_loader( + repre_contexts, loader) + if len(filtered_repre_contexts) != len(repre_contexts): + continue + + item = self._create_loader_action_item( + loader, + repre_contexts, + project_name=project_name, + folder_ids=repre_folder_ids, + product_ids=repre_product_ids, + version_ids=repre_version_ids, + representation_ids=repre_ids + ) + action_items.append(item) + + # Subset Loaders. + product_ids = set(product_context_by_id.keys()) + product_folder_ids = set() + for product_context in product_context_by_id.values(): + product_folder_ids.add(product_context["asset"]["_id"]) + + product_contexts = list(product_context_by_id.values()) + for loader in product_loaders: + item = self._create_loader_action_item( + loader, + product_contexts, + project_name=project_name, + folder_ids=product_folder_ids, + product_ids=product_ids, + ) + action_items.append(item) + + action_items.sort(key=self._actions_sorter) + return action_items + + def get_representations_action_items( + self, project_name, representation_ids + ): + return [] + + def trigger_action_item( + self, + identifier, + options, + project_name, + folder_ids, + product_ids, + version_ids, + representation_ids + ): + loader = self._get_loader_by_identifier(project_name, identifier) + if representation_ids is not None: + self._trigger_representation_loader( + loader, + options, + project_name, + folder_ids, + product_ids, + version_ids, + representation_ids, + ) + elif product_ids is not None: + self._trigger_product_loader( + loader, + options, + project_name, + folder_ids, + product_ids, + ) + else: + raise NotImplementedError( + "Invalid arguments to trigger action item") + + def _get_loader_label(self, 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 = "{} ({})".format(label, representation["name"]) + return label + + def _get_loader_icon(self, 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 and not isinstance(icon, dict): + icon = { + "type": "awesome-font", + "name": icon, + "color": getattr(loader, "color", None) or "white" + } + return icon + + def _get_loader_tooltip(self, loader): + # Add tooltip and statustip from Loader docstring + return inspect.getdoc(loader) + + def _filter_loaders_by_tool_name(self, loaders): + if not self._tool_name: + return loaders + filtered_loaders = [] + for loader in loaders: + tool_names = getattr(loader, "tool_names", None) + if ( + tool_names is None + or "*" in tool_names + or self._tool_name in tool_names + ): + filtered_loaders.append(loader) + return filtered_loaders + + def _create_loader_action_item( + self, + loader, + contexts, + project_name, + folder_ids=None, + product_ids=None, + version_ids=None, + representation_ids=None + ): + return ActionItem( + get_loader_identifier(loader), + label=self._get_loader_label(loader), + icon=self._get_loader_icon(loader), + tooltip=self._get_loader_tooltip(loader), + options=loader.get_options(contexts), + order=loader.order, + project_name=project_name, + folder_ids=folder_ids, + product_ids=product_ids, + version_ids=version_ids, + representation_ids=representation_ids, + ) + + def _get_loaders(self, project_name): + """ + + TODOs: + Cache loaders for time period and reset them on controller + refresh. + Cache them per project name. Right now they are collected per + project, but not cached per project. + + Questions: + Project name is required because of settings. Should be actually + pass in current project name instead of project name where + we want to show loaders for? + """ + + loaders_by_identifier_c = self._loaders_by_identifier[project_name] + product_loaders_c = self._product_loaders[project_name] + repre_loaders_c = self._repre_loaders[project_name] + if loaders_by_identifier_c.is_valid: + return product_loaders_c.get_data(), repre_loaders_c.get_data() + + # Get all representation->loader combinations available for the + # index under the cursor, so we can list the user the options. + available_loaders = self._filter_loaders_by_tool_name( + discover_loader_plugins(project_name) + ) + + repre_loaders = [] + product_loaders = [] + loaders_by_identifier = {} + for loader_cls in available_loaders: + if not loader_cls.enabled: + continue + + identifier = get_loader_identifier(loader_cls) + loaders_by_identifier[identifier] = loader_cls + if issubclass(loader_cls, SubsetLoaderPlugin): + product_loaders.append(loader_cls) + else: + repre_loaders.append(loader_cls) + + loaders_by_identifier_c.update_data(loaders_by_identifier) + product_loaders_c.update_data(product_loaders) + repre_loaders_c.update_data(repre_loaders) + return product_loaders, repre_loaders + + def _get_loader_by_identifier(self, project_name, identifier): + loaders_by_identifier_c = self._loaders_by_identifier[project_name] + if not loaders_by_identifier_c.is_valid: + self._get_loaders(project_name) + loaders_by_identifier = loaders_by_identifier_c.get_data() + return loaders_by_identifier.get(identifier) + + def _actions_sorter(self, action_item): + """Sort the Loaders by their order and then their name""" + + return action_item.order, action_item.label + + def _contexts_for_versions(self, project_name, version_ids): + _version_docs = get_versions(project_name, version_ids) + version_docs_by_id = {} + version_docs_by_product_id = collections.defaultdict(list) + for version_doc in _version_docs: + version_id = version_doc["_id"] + product_id = version_doc["parent"] + version_docs_by_id[version_id] = version_doc + version_docs_by_product_id[product_id].append(version_doc) + + _product_ids = set(version_docs_by_product_id.keys()) + _product_docs = get_subsets(project_name, subset_ids=_product_ids) + product_docs_by_id = {p["_id"]: p for p in _product_docs} + + _folder_ids = {p["parent"] for p in product_docs_by_id.values()} + _folder_docs = get_assets(project_name, asset_ids=_folder_ids) + folder_docs_by_id = {f["_id"]: f for f in _folder_docs} + + project_doc = get_project(project_name) + project_doc["code"] = project_doc["data"]["code"] + + repre_context_by_id = {} + product_context_by_id = {} + for product_id, product_doc in product_docs_by_id.items(): + folder_id = product_doc["parent"] + folder_doc = folder_docs_by_id[folder_id] + product_context_by_id[product_id] = { + "project": project_doc, + "asset": folder_doc, + "subset": product_doc, + } + + repre_docs = get_representations( + project_name, version_ids=version_ids) + for repre_doc in repre_docs: + version_id = repre_doc["parent"] + version_doc = version_docs_by_id[version_id] + product_id = version_doc["parent"] + product_doc = product_docs_by_id[product_id] + folder_id = product_doc["parent"] + folder_doc = folder_docs_by_id[folder_id] + + repre_context_by_id[repre_doc["_id"]] = { + "project": project_doc, + "asset": folder_doc, + "subset": product_doc, + "version": version_doc, + "representation": repre_doc, + } + + return product_context_by_id, repre_context_by_id + + def _trigger_product_loader( + self, + loader, + options, + project_name, + folder_ids, + product_ids, + ): + project_doc = get_project(project_name) + project_doc["code"] = project_doc["data"]["code"] + folder_docs = get_assets(project_name, asset_ids=folder_ids) + folder_docs_by_id = {f["_id"]: f for f in folder_docs} + product_docs = get_subsets(project_name, subset_ids=product_ids) + product_contexts = [] + for product_doc in product_docs: + folder_id = product_doc["parent"] + folder_doc = folder_docs_by_id[folder_id] + product_contexts.append({ + "project": project_doc, + "asset": folder_doc, + "subset": product_doc, + }) + + if loader.is_multiple_contexts_compatible: + load_with_subset_contexts(loader, product_contexts, options) + else: + for product_context in product_contexts: + load_with_subset_context(loader, product_context, options) + + def _trigger_representation_loader( + self, + loader, + options, + project_name, + folder_ids, + product_ids, + version_ids, + representation_ids, + ): + project_doc = get_project(project_name) + project_doc["code"] = project_doc["data"]["code"] + folder_docs = get_assets(project_name, asset_ids=folder_ids) + folder_docs_by_id = {f["_id"]: f for f in folder_docs} + product_docs = get_subsets(project_name, subset_ids=product_ids) + product_docs_by_id = {p["_id"]: p for p in product_docs} + version_docs = get_versions(project_name, version_ids=version_ids) + version_docs_by_id = {v["_id"]: v for v in version_docs} + repre_docs = get_representations( + project_name, representation_ids=representation_ids + ) + repre_contexts = [] + for repre_doc in repre_docs: + version_id = repre_doc["parent"] + version_doc = version_docs_by_id[version_id] + product_id = version_doc["parent"] + product_doc = product_docs_by_id[product_id] + folder_id = product_doc["parent"] + folder_doc = folder_docs_by_id[folder_id] + repre_contexts.append({ + "project": project_doc, + "asset": folder_doc, + "subset": product_doc, + "version": version_doc, + "representation": repre_doc, + }) + + for repre_context in repre_contexts: + load_with_repre_context(loader, repre_context, options=options) diff --git a/openpype/tools/ayon_loader/ui/products_model.py b/openpype/tools/ayon_loader/ui/products_model.py index ed542bf902b..8916c2ad827 100644 --- a/openpype/tools/ayon_loader/ui/products_model.py +++ b/openpype/tools/ayon_loader/ui/products_model.py @@ -297,6 +297,9 @@ def _get_product_model_item(self, product_item): self._set_version_data_to_product_item(model_item, last_version) return model_item + def get_last_project_name(self): + return self._last_project_name + def refresh(self, project_name, folder_ids): self._clear() diff --git a/openpype/tools/ayon_loader/ui/products_widget.py b/openpype/tools/ayon_loader/ui/products_widget.py index d5390233c05..ad5023482c5 100644 --- a/openpype/tools/ayon_loader/ui/products_widget.py +++ b/openpype/tools/ayon_loader/ui/products_widget.py @@ -1,11 +1,23 @@ import collections -from qtpy import QtWidgets, QtCore +import uuid -from openpype.tools.utils.delegates import PrettyTimeDelegate +import qtawesome +from qtpy import QtWidgets, QtCore, QtGui + + +from openpype.lib.attribute_definitions import AbstractAttrDef +from openpype.tools.attribute_defs import AttributeDefinitionsDialog from openpype.tools.utils import ( RecursiveSortFilterProxyModel, DeselectableTreeView, ) +from openpype.tools.utils.delegates import PrettyTimeDelegate +from openpype.tools.utils.widgets import ( + OptionalMenu, + OptionalAction, + OptionDialog, +) +from openpype.tools.ayon_utils.widgets import get_qt_icon from .products_model import ( ProductsModel, @@ -13,6 +25,7 @@ PRODUCT_TYPE_ROLE, GROUP_TYPE_ROLE, PRODUCT_ID_ROLE, + VERSION_ID_ROLE, ) from .products_delegates import VersionDelegate @@ -127,8 +140,9 @@ def __init__(self, controller, parent): products_proxy_model.rowsInserted.connect(self._on_rows_inserted) products_proxy_model.rowsMoved.connect(self._on_rows_moved) - products_proxy_model.rowsRemoved.connect(self._on_rows_removed) products_model.refreshed.connect(self._on_refresh) + products_view.customContextMenuRequested.connect( + self._on_context_menu) controller.register_event_callback( "selection.folders.changed", @@ -216,15 +230,143 @@ def _on_rows_inserted(self): def _on_rows_moved(self): self._fill_version_editor() - def _on_rows_removed(self): - self._fill_version_editor() - def _refresh_model(self): self._products_model.refresh( self._selected_project_name, self._selected_folder_ids ) + def _on_context_menu(self, point): + selection_model = self._products_view.selectionModel() + model = self._products_view.model() + project_name = self._products_model.get_last_project_name() + + version_ids = set() + indexes_queue = collections.deque() + indexes_queue.extend(selection_model.selectedIndexes()) + while indexes_queue: + index = indexes_queue.popleft() + for row in range(model.rowCount(index)): + child_index = model.index(row, 0, index) + indexes_queue.append(child_index) + version_id = model.data(index, VERSION_ID_ROLE) + if version_id is not None: + version_ids.add(version_id) + + action_items = self._controller.get_versions_action_items( + project_name, version_ids) + + # Prepare global point where to show the menu + global_point = self._products_view.mapToGlobal(point) + if not action_items: + menu = QtWidgets.QMenu(self) + action = self._get_no_loader_action(menu, len(version_ids) == 1) + menu.addAction(action) + menu.exec_(global_point) + return + + menu = OptionalMenu(self) + + action_items_by_id = {} + for action_item in action_items: + item_id = uuid.uuid4().hex + action_items_by_id[item_id] = action_item + options = action_item.options + icon = get_qt_icon(action_item.icon) + use_option = bool(options) + action = OptionalAction( + action_item.label, + icon, + use_option, + menu + ) + if use_option: + # Add option box tip + action.set_option_tip(options) + + tip = action_item.tooltip + if tip: + action.setToolTip(tip) + action.setStatusTip(tip) + + action.setData(item_id) + + menu.addAction(action) + + action = menu.exec_(global_point) + action_item = None + if action is not None: + item_id = action.data() + action_item = action_items_by_id.get(item_id) + if action_item is None: + return + + options = self._get_options(action, action_item, self) + if options is None: + return + self._controller.trigger_action_item( + action_item.identifier, + options, + action_item.project_name, + action_item.folder_ids, + action_item.product_ids, + action_item.version_ids, + action_item.representation_ids, + ) + + def _get_options(self, action, action_item, parent): + """Provides dialog to select value from loader provided options. + + Loader can provide static or dynamically created options based on + AttributeDefinitions, and for backwards compatibility qargparse. + + Args: + action (OptionalAction) - Action object in menu. + action_item (ActionItem) - Action item with context information. + parent (QtCore.QObject) - Parent object for dialog. + + Returns: + Union[dict[str, Any], None]: Selected value from attributes or + 'None' if dialog was cancelled. + """ + + # Pop option dialog + options = action_item.options + if not getattr(action, "optioned", False) or not options: + return {} + + if isinstance(options[0], AbstractAttrDef): + qargparse_options = False + dialog = AttributeDefinitionsDialog(options, parent) + else: + qargparse_options = True + dialog = OptionDialog(parent) + dialog.create(options) + + dialog.setWindowTitle(action.label + " Options") + + if not dialog.exec_(): + return None + + # Get option + if qargparse_options: + return dialog.parse() + return dialog.get_values() + + def _get_no_loader_action(self, menu, one_item_selected): + """Creates dummy no loader option in 'menu'""" + + if one_item_selected: + submsg = "this version." + else: + submsg = "your selection." + msg = "No compatible loaders for {}".format(submsg) + icon = qtawesome.icon( + "fa.exclamation", + color=QtGui.QColor(255, 51, 0) + ) + return QtWidgets.QAction(icon, ("*" + msg), menu) + def _on_folders_selection_change(self, event): self._selected_project_name = event["project_name"] self._selected_folder_ids = event["folder_ids"] From da9e9a8047dba7a57291475f12f759da1a888879 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 2 Oct 2023 14:37:16 +0200 Subject: [PATCH 13/69] implemented folder underline colors --- .../tools/ayon_loader/ui/folders_widget.py | 68 ++++++++++++++++--- .../tools/ayon_loader/ui/products_model.py | 13 ++-- .../tools/ayon_loader/ui/products_widget.py | 52 ++++++++++++++ openpype/tools/ayon_loader/ui/window.py | 10 ++- 4 files changed, 129 insertions(+), 14 deletions(-) diff --git a/openpype/tools/ayon_loader/ui/folders_widget.py b/openpype/tools/ayon_loader/ui/folders_widget.py index 2ce54b93184..7bb7200f1e9 100644 --- a/openpype/tools/ayon_loader/ui/folders_widget.py +++ b/openpype/tools/ayon_loader/ui/folders_widget.py @@ -24,17 +24,17 @@ UNDERLINE_COLORS_ROLE = QtCore.Qt.UserRole + 4 -class UnderlinesAssetDelegate(QtWidgets.QItemDelegate): - """Item delegate drawing bars under asset name. +class UnderlinesFolderDelegate(QtWidgets.QItemDelegate): + """Item delegate drawing bars under folder label. - This is used in loader and library loader tools. Multiselection of assets - may group subsets by name under colored groups. Selected color groups are - then propagated back to selected assets as underlines. + This is used in loader tool. Multiselection of folders + may group products by name under colored groups. Selected color groups are + then propagated back to selected folders as underlines. """ bar_height = 3 def __init__(self, *args, **kwargs): - super(UnderlinesAssetDelegate, self).__init__(*args, **kwargs) + super(UnderlinesFolderDelegate, self).__init__(*args, **kwargs) colors = get_objected_colors("loader", "asset-view") self._selected_color = colors["selected"].get_qcolor() self._hover_color = colors["hover"].get_qcolor() @@ -42,7 +42,7 @@ def __init__(self, *args, **kwargs): def sizeHint(self, option, index): """Add bar height to size hint.""" - result = super(UnderlinesAssetDelegate, self).sizeHint(option, index) + result = super(UnderlinesFolderDelegate, self).sizeHint(option, index) height = result.height() result.setHeight(height + self.bar_height) @@ -60,6 +60,7 @@ def paint(self, painter, option, index): item_rect.setHeight(option.rect.height() - self.bar_height) subset_colors = index.data(UNDERLINE_COLORS_ROLE) or [] + subset_colors_width = 0 if subset_colors: subset_colors_width = option.rect.width() / len(subset_colors) @@ -70,7 +71,7 @@ def paint(self, painter, option, index): new_color = None new_rect = None if subset_c: - new_color = QtGui.QColor(*subset_c) + new_color = QtGui.QColor(subset_c) new_rect = QtCore.QRect( option.rect.left() + (counter * subset_colors_width), @@ -185,6 +186,11 @@ def paint(self, painter, option, index): class LoaderFoldersModel(FoldersModel): + def __init__(self, *args, **kwargs): + super(LoaderFoldersModel, self).__init__(*args, **kwargs) + + self._colored_items = set() + def _fill_item_data(self, item, folder_item): """ @@ -195,6 +201,38 @@ def _fill_item_data(self, item, folder_item): super(LoaderFoldersModel, self)._fill_item_data(item, folder_item) + def set_merged_products_selection(self, items): + changes = { + folder_id: None + for folder_id in self._colored_items + } + + all_folder_ids = set() + for item in items: + folder_ids = item["folder_ids"] + all_folder_ids.update(folder_ids) + + for folder_id in all_folder_ids: + changes[folder_id] = [] + + for item in items: + item_color = item["color"] + item_folder_ids = item["folder_ids"] + for folder_id in all_folder_ids: + folder_color = ( + item_color + if folder_id in item_folder_ids + else None + ) + changes[folder_id].append(folder_color) + + for folder_id, color_value in changes.items(): + item = self._items_by_id.get(folder_id) + if item is not None: + item.setData(color_value, UNDERLINE_COLORS_ROLE) + + self._colored_items = all_folder_ids + class LoaderFoldersWidget(QtWidgets.QWidget): """Folders widget. @@ -238,7 +276,10 @@ def __init__(self, controller, parent, handle_expected_selection=False): folders_proxy_model = RecursiveSortFilterProxyModel() folders_proxy_model.setSourceModel(folders_model) + folders_label_delegate = UnderlinesFolderDelegate(folders_view) + folders_view.setModel(folders_proxy_model) + folders_view.setItemDelegate(folders_label_delegate) main_layout = QtWidgets.QHBoxLayout(self) main_layout.setContentsMargins(0, 0, 0, 0) @@ -270,6 +311,7 @@ def __init__(self, controller, parent, handle_expected_selection=False): self._folders_view = folders_view self._folders_model = folders_model self._folders_proxy_model = folders_proxy_model + self._folders_label_delegate = folders_label_delegate self._handle_expected_selection = handle_expected_selection self._expected_selection = None @@ -283,6 +325,16 @@ def set_name_filer(self, name): self._folders_proxy_model.setFilterFixedString(name) + def set_merged_products_selection(self, items): + """ + + Args: + items (list[dict[str, Any]]): List of merged items with folder + ids. + """ + + self._folders_model.set_merged_products_selection(items) + def _on_project_selection_change(self, event): project_name = event["project_name"] self._set_project_name(project_name) diff --git a/openpype/tools/ayon_loader/ui/products_model.py b/openpype/tools/ayon_loader/ui/products_model.py index 8916c2ad827..8c871c92f3a 100644 --- a/openpype/tools/ayon_loader/ui/products_model.py +++ b/openpype/tools/ayon_loader/ui/products_model.py @@ -8,8 +8,9 @@ PRODUCTS_MODEL_SENDER_NAME = "qt_products_model" GROUP_TYPE_ROLE = QtCore.Qt.UserRole + 1 -FOLDER_LABEL_ROLE = QtCore.Qt.UserRole + 2 -FOLDER_ID_ROLE = QtCore.Qt.UserRole + 3 +MERGED_COLOR_ROLE = QtCore.Qt.UserRole + 2 +FOLDER_LABEL_ROLE = QtCore.Qt.UserRole + 3 +FOLDER_ID_ROLE = QtCore.Qt.UserRole + 4 PRODUCT_ID_ROLE = QtCore.Qt.UserRole + 5 PRODUCT_NAME_ROLE = QtCore.Qt.UserRole + 6 PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 7 @@ -80,6 +81,7 @@ def __init__(self, controller): self._product_items_by_id = {} self._grouping_enabled = False self._reset_merge_color = False + self._color_iterator = self._color_iter() self._last_project_name = None self._last_folder_ids = [] @@ -206,7 +208,7 @@ def setData(self, index, value, role=None): return super(ProductsModel, self).setData(index, value, role) def _get_next_color(self): - return next(self._color_iter()) + return next(self._color_iterator) def _color_iter(self): while True: @@ -236,11 +238,12 @@ def _get_group_model_item(self, group_name): self._group_items_by_name[group_name] = model_item return model_item - def _get_merged_model_item(self, path, count): + def _get_merged_model_item(self, path, count, hex_color): model_item = self._merged_items_by_id.get(path) if model_item is None: model_item = QtGui.QStandardItem() model_item.setData(1, GROUP_TYPE_ROLE) + model_item.setData(hex_color, MERGED_COLOR_ROLE) model_item.setEditable(False) model_item.setColumnCount(self.columnCount()) self._merged_items_by_id[path] = model_item @@ -372,7 +375,7 @@ def refresh(self, project_name, folder_ids): merged_color = qtawesome.icon( "fa.circle", color=merged_color_qt) merged_item = self._get_merged_model_item( - path, len(product_items)) + path, len(product_items), merged_color_hex) merged_item.setData(merged_color, QtCore.Qt.DecorationRole) new_items.append(merged_item) diff --git a/openpype/tools/ayon_loader/ui/products_widget.py b/openpype/tools/ayon_loader/ui/products_widget.py index ad5023482c5..037793c3947 100644 --- a/openpype/tools/ayon_loader/ui/products_widget.py +++ b/openpype/tools/ayon_loader/ui/products_widget.py @@ -24,6 +24,8 @@ PRODUCTS_MODEL_SENDER_NAME, PRODUCT_TYPE_ROLE, GROUP_TYPE_ROLE, + MERGED_COLOR_ROLE, + FOLDER_ID_ROLE, PRODUCT_ID_ROLE, VERSION_ID_ROLE, ) @@ -83,6 +85,7 @@ def sort(self, column, order=None): class ProductsWidget(QtWidgets.QWidget): + merged_products_selection_changed = QtCore.Signal() default_widths = ( 200, # Product name 90, # Product type @@ -143,6 +146,8 @@ def __init__(self, controller, parent): products_model.refreshed.connect(self._on_refresh) products_view.customContextMenuRequested.connect( self._on_context_menu) + products_view.selectionModel().selectionChanged.connect( + self._on_selection_change) controller.register_event_callback( "selection.folders.changed", @@ -171,6 +176,8 @@ def __init__(self, controller, parent): self._selected_project_name = None self._selected_folder_ids = set() + self._selected_merged_products = [] + # Set initial state of widget self._update_folders_label_visible() @@ -198,6 +205,9 @@ def set_product_type_filter(self, product_type_filters): def set_enable_grouping(self, enable_grouping): self._products_model.set_enable_grouping(enable_grouping) + def get_merged_products_selection(self): + return self._selected_merged_products + def _fill_version_editor(self): model = self._products_proxy_model index_queue = collections.deque() @@ -314,6 +324,48 @@ def _on_context_menu(self, point): action_item.representation_ids, ) + def _on_selection_change(self): + selected_merged_products = [] + selection_model = self._products_view.selectionModel() + model = self._products_view.model() + indexes_queue = collections.deque() + indexes_queue.extend(selection_model.selectedIndexes()) + while indexes_queue: + index = indexes_queue.popleft() + if index.column() != 0: + continue + + group_type = model.data(index, GROUP_TYPE_ROLE) + if group_type == 0: + for row in range(model.rowCount(index)): + child_index = model.index(row, 0, index) + indexes_queue.append(child_index) + continue + + if group_type != 1: + continue + + item_folder_ids = set() + for row in range(model.rowCount(index)): + child_index = model.index(row, 0, index) + folder_id = model.data(child_index, FOLDER_ID_ROLE) + item_folder_ids.add(folder_id) + + if not item_folder_ids: + continue + + hex_color = model.data(index, MERGED_COLOR_ROLE) + item_data = { + "color": hex_color, + "folder_ids": item_folder_ids + } + selected_merged_products.append(item_data) + + prev_selected_merged_products = self._selected_merged_products + self._selected_merged_products = selected_merged_products + if selected_merged_products != prev_selected_merged_products: + self.merged_products_selection_changed.emit() + def _get_options(self, action, action_item, parent): """Provides dialog to select value from loader provided options. diff --git a/openpype/tools/ayon_loader/ui/window.py b/openpype/tools/ayon_loader/ui/window.py index b588502274a..809ffb9149c 100644 --- a/openpype/tools/ayon_loader/ui/window.py +++ b/openpype/tools/ayon_loader/ui/window.py @@ -100,7 +100,11 @@ def __init__(self, controller=None, parent=None): self._on_product_filter_change ) product_group_checkbox.stateChanged.connect( - self._on_product_group_change) + self._on_product_group_change + ) + products_widget.merged_products_selection_changed.connect( + self._on_merged_products_selection_change + ) self._projects_combobox = projects_combobox @@ -164,3 +168,7 @@ def _on_product_type_filter_change(self): self._products_widget.set_product_type_filter( self._product_types_widget.get_filter_info() ) + + def _on_merged_products_selection_change(self): + items = self._products_widget.get_merged_products_selection() + self._folders_widget.set_merged_products_selection(items) From 7dc5b6a6452d1673d74f024630aec655b990e5c7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 2 Oct 2023 18:57:14 +0200 Subject: [PATCH 14/69] changed version items to dictionary --- openpype/tools/ayon_loader/abstract.py | 16 ++++++++-------- openpype/tools/ayon_loader/models/products.py | 6 +++--- openpype/tools/ayon_loader/ui/products_model.py | 8 ++++---- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/openpype/tools/ayon_loader/abstract.py b/openpype/tools/ayon_loader/abstract.py index c20547fa57d..abc98aafb9a 100644 --- a/openpype/tools/ayon_loader/abstract.py +++ b/openpype/tools/ayon_loader/abstract.py @@ -108,18 +108,18 @@ def to_data(self): "group_name": self.group_name, "folder_id": self.folder_id, "folder_label": self.folder_label, - "version_items": [ - version_item.to_data() - for version_item in self.version_items - ], + "version_items": { + version_id: version_item.to_data() + for version_id, version_item in self.version_items.items() + }, } @classmethod def from_data(cls, data): - version_items = [ - VersionItem.from_data(version) - for version in data["version_items"] - ] + version_items = { + version_id: VersionItem.from_data(version) + for version_id, version in data["version_items"].items() + } data["version_items"] = version_items return cls(**data) diff --git a/openpype/tools/ayon_loader/models/products.py b/openpype/tools/ayon_loader/models/products.py index aec0d85413e..4af6ce99e93 100644 --- a/openpype/tools/ayon_loader/models/products.py +++ b/openpype/tools/ayon_loader/models/products.py @@ -79,10 +79,10 @@ def product_item_from_entity( "name": "fa.file-o", "color": get_default_entity_icon_color(), } - version_items = [ - version_item_from_entity(version_entity) + version_items = { + version_entity["id"]: version_item_from_entity(version_entity) for version_entity in version_entities - ] + } return ProductItem( product_id=product_entity["id"], diff --git a/openpype/tools/ayon_loader/ui/products_model.py b/openpype/tools/ayon_loader/ui/products_model.py index 8c871c92f3a..4902e0aefcc 100644 --- a/openpype/tools/ayon_loader/ui/products_model.py +++ b/openpype/tools/ayon_loader/ui/products_model.py @@ -139,7 +139,7 @@ def data(self, index, role=None): product_item = self._product_items_by_id.get(product_id) if product_item is None: return None - return product_item.version_items + return list(product_item.version_items.values()) if role == QtCore.Qt.EditRole: return None @@ -193,8 +193,8 @@ def setData(self, index, value, role=None): product_id = index.data(PRODUCT_ID_ROLE) product_item = self._product_items_by_id[product_id] final_version_item = None - for version_item in product_item.version_items: - if version_item.version_id == value: + for v_id, version_item in product_item.version_items.items(): + if v_id == value: final_version_item = version_item break @@ -277,7 +277,7 @@ def _set_version_data_to_product_item(self, model_item, version_item): def _get_product_model_item(self, product_item): model_item = self._items_by_id.get(product_item.product_id) - versions = list(product_item.version_items) + versions = list(product_item.version_items.values()) versions.sort() last_version = versions[-1] if model_item is None: From 34366346bc1a14ee2845423d6fda8da21c1aab69 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 2 Oct 2023 18:57:58 +0200 Subject: [PATCH 15/69] use 'product_id' instead of 'subset_id' --- openpype/tools/ayon_loader/abstract.py | 8 ++++---- openpype/tools/ayon_loader/models/products.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/openpype/tools/ayon_loader/abstract.py b/openpype/tools/ayon_loader/abstract.py index abc98aafb9a..00427d51295 100644 --- a/openpype/tools/ayon_loader/abstract.py +++ b/openpype/tools/ayon_loader/abstract.py @@ -8,7 +8,7 @@ def __init__( version_id, version, is_hero, - subset_id, + product_id, thumbnail_id, published_time, author, @@ -19,7 +19,7 @@ def __init__( in_scene ): self.version_id = version_id - self.subset_id = subset_id + self.product_id = product_id self.thumbnail_id = thumbnail_id self.version = version self.is_hero = is_hero @@ -38,7 +38,7 @@ def __eq__(self, other): self.is_hero == other.is_hero and self.version == other.version and self.version_id == other.version_id - and self.subset_id == other.subset_id + and self.product_id == other.product_id ) def __ne__(self, other): @@ -57,7 +57,7 @@ def __gt__(self, other): def to_data(self): return { "version_id": self.version_id, - "subset_id": self.subset_id, + "product_id": self.product_id, "thumbnail_id": self.thumbnail_id, "version": self.version, "is_hero": self.is_hero, diff --git a/openpype/tools/ayon_loader/models/products.py b/openpype/tools/ayon_loader/models/products.py index 4af6ce99e93..402efc55776 100644 --- a/openpype/tools/ayon_loader/models/products.py +++ b/openpype/tools/ayon_loader/models/products.py @@ -50,7 +50,7 @@ def version_item_from_entity(version): version_id=version["id"], version=version_num, is_hero=is_hero, - subset_id=version["productId"], + product_id=version["productId"], thumbnail_id=version["thumbnailId"], published_time=published_time, author=author, From 17c2d7bfe0557bf6972abed3761ca84c7fddfa88 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 2 Oct 2023 18:58:52 +0200 Subject: [PATCH 16/69] base implementation of info widget --- openpype/tools/ayon_loader/abstract.py | 71 ++++++++- openpype/tools/ayon_loader/control.py | 33 +++- openpype/tools/ayon_loader/models/products.py | 11 ++ openpype/tools/ayon_loader/ui/info_widget.py | 142 ++++++++++++++++++ .../tools/ayon_loader/ui/products_model.py | 17 +++ .../tools/ayon_loader/ui/products_widget.py | 41 ++++- .../tools/ayon_loader/ui/repres_widget.py | 16 ++ openpype/tools/ayon_loader/ui/window.py | 36 ++++- 8 files changed, 359 insertions(+), 8 deletions(-) create mode 100644 openpype/tools/ayon_loader/ui/info_widget.py create mode 100644 openpype/tools/ayon_loader/ui/repres_widget.py diff --git a/openpype/tools/ayon_loader/abstract.py b/openpype/tools/ayon_loader/abstract.py index 00427d51295..4ded0b4c153 100644 --- a/openpype/tools/ayon_loader/abstract.py +++ b/openpype/tools/ayon_loader/abstract.py @@ -16,7 +16,9 @@ def __init__( duration, handles, step, - in_scene + in_scene, + comment, + source ): self.version_id = version_id self.product_id = product_id @@ -30,6 +32,8 @@ def __init__( self.handles = handles self.step = step self.in_scene = in_scene + self.comment = comment + self.source = source def __eq__(self, other): if not isinstance(other, VersionItem): @@ -68,6 +72,8 @@ def to_data(self): "handles": self.handles, "step": self.step, "in_scene": self.in_scene, + "comment": self.comment, + "source": self.source, } @classmethod @@ -144,6 +150,14 @@ def from_data(cls, data): @six.add_metaclass(ABCMeta) class AbstractController: + @abstractmethod + def emit_event(self, topic, data=None, source=None): + pass + + @abstractmethod + def register_event_callback(self, topic, callback): + pass + @abstractmethod def get_current_project(self): pass @@ -157,10 +171,61 @@ def reset(self): def get_project_items(self): pass + @abstractmethod + def get_folder_items(self, project_name, sender=None): + pass + + @abstractmethod + def get_product_items(self, project_name, folder_ids, sender=None): + pass + + @abstractmethod + def get_product_item(self, project_name, folder_id, product_id): + """ + + Args: + project_name (str): Project name. + folder_id (str): Folder id. + product_id (str): Product id. + + Returns: + Union[ProductItem, None]: Product info or None if not found. + """ + + pass + @abstractmethod def get_product_type_items(self, project_name): pass + @abstractmethod + def get_folder_entity(self, project_name, folder_id): + pass + + # Load action items + @abstractmethod + def get_versions_action_items(self, project_name, version_ids): + pass + + @abstractmethod + def get_representations_action_items( + self, project_name, representation_ids + ): + pass + + @abstractmethod + def trigger_action_item( + self, + identifier, + options, + project_name, + folder_ids, + product_ids, + version_ids, + representation_ids, + ): + pass + # Selection model wrapper calls @abstractmethod def get_selected_project_name(self): @@ -177,3 +242,7 @@ def get_selected_folder_ids(self): @abstractmethod def set_selected_folders(self, folder_ids): pass + + @abstractmethod + def fill_root_in_source(self, source): + pass diff --git a/openpype/tools/ayon_loader/control.py b/openpype/tools/ayon_loader/control.py index 557dea8e137..63f3aaccf5e 100644 --- a/openpype/tools/ayon_loader/control.py +++ b/openpype/tools/ayon_loader/control.py @@ -1,8 +1,12 @@ import logging from openpype.lib.events import QueuedEventSystem - -from openpype.tools.ayon_utils.models import ProjectsModel, HierarchyModel +from openpype.pipeline import Anatomy +from openpype.tools.ayon_utils.models import ( + ProjectsModel, + HierarchyModel, + NestedCacheItem, +) from .abstract import AbstractController from .models import SelectionModel, ProductsModel, LoaderActionsModel @@ -13,6 +17,7 @@ def __init__(self): self._log = None self._event_system = self._create_event_system() + self._project_anatomy_cache = NestedCacheItem(levels=1) self._selection_model = SelectionModel(self) self._projects_model = ProjectsModel(self) self._hierarchy_model = HierarchyModel(self) @@ -60,6 +65,11 @@ def get_product_items(self, project_name, folder_ids, sender=None): return self._products_model.get_product_items( project_name, folder_ids, sender) + def get_product_item(self, project_name, folder_id, product_id): + return self._products_model.get_product_item( + project_name, folder_id, product_id + ) + def get_product_type_items(self, project_name): return self._products_model.get_product_type_items(project_name) @@ -109,6 +119,25 @@ def get_selected_folder_ids(self): def set_selected_folders(self, folder_ids): self._selection_model.set_selected_folders(folder_ids) + def fill_root_in_source(self, source): + project_name = self.get_selected_project_name() + anatomy = self._get_project_anatomy(project_name) + if anatomy is None: + return source + + try: + return anatomy.fill_root(source) + except Exception: + return source + + def _get_project_anatomy(self, project_name): + if not project_name: + return None + cache = self._project_anatomy_cache[project_name] + if not cache.is_valid: + cache.update_data(Anatomy(project_name)) + return cache.get_data() + def _create_event_system(self): return QueuedEventSystem() diff --git a/openpype/tools/ayon_loader/models/products.py b/openpype/tools/ayon_loader/models/products.py index 402efc55776..b3b2e7af115 100644 --- a/openpype/tools/ayon_loader/models/products.py +++ b/openpype/tools/ayon_loader/models/products.py @@ -22,6 +22,8 @@ def version_item_from_entity(version): handle_start = version_attribs.get("handleStart") handle_end = version_attribs.get("handleEnd") step = version_attribs.get("step") + comment = version_attribs.get("comment") + source = version_attribs.get("source") frame_range = None duration = None @@ -59,6 +61,8 @@ def version_item_from_entity(version): handles=handles, step=step, in_scene=None, + comment=comment, + source=source, ) @@ -183,6 +187,13 @@ def get_repre_items(self, project_name, version_ids): output.update(data) return output + def get_product_item(self, project_name, folder_id, product_id): + if not any((project_name, folder_id, product_id)): + return None + project_cache = self._product_items_cache[project_name] + product_items_by_folder_id = project_cache[folder_id].get_data() + return product_items_by_folder_id.get(product_id) + def _refresh_product_items(self, project_name, folder_ids, sender): if not project_name or not folder_ids: return diff --git a/openpype/tools/ayon_loader/ui/info_widget.py b/openpype/tools/ayon_loader/ui/info_widget.py new file mode 100644 index 00000000000..f696216af08 --- /dev/null +++ b/openpype/tools/ayon_loader/ui/info_widget.py @@ -0,0 +1,142 @@ +import datetime + +from qtpy import QtWidgets + +from openpype.tools.utils.lib import format_version + + +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, controller, parent): + super(VersionTextEdit, self).__init__(parent=parent) + + self._version_item = None + self._product_item = None + + self._controller = controller + + # Reset + self.set_current_item() + + def set_current_item(self, product_item=None, version_item=None): + """ + + Args: + product_item (Union[ProductItem, None]): Product item. + version_item (Union[VersionItem, None]): Version item to display. + """ + + self._product_item = product_item + self._version_item = version_item + + if version_item is None: + # Reset state to empty + self.setText("") + return + + version_label = format_version(abs(version_item.version)) + if version_item.version < 0: + version_label = "Hero version {}".format(version_label) + + # Define readable creation timestamp + created = version_item.published_time + created = datetime.datetime.strptime(created, "%Y%m%dT%H%M%SZ") + created = datetime.datetime.strftime(created, "%b %d %Y %H:%M") + + comment = version_item.comment or "No comment" + source = version_item.source or "No source" + + self.setHtml( + ( + "

{product_name}

" + "

{version_label}

" + "Comment
" + "{comment}

" + + "Created
" + "{created}

" + + "Source
" + "{source}" + ).format( + product_name=product_item.product_name, + version_label=version_label, + comment=comment, + created=created, + source=source, + ) + ) + + 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. + source = None + if self._version_item is not None: + source = self._version_item.source + + if source: + menu.addSeparator() + action = QtWidgets.QAction( + "Copy source path to clipboard", menu + ) + action.triggered.connect(self._on_copy_source) + menu.addAction(action) + + menu.exec_(event.globalPos()) + + def _on_copy_source(self): + """Copy formatted source path to clipboard.""" + + source = self._version_item.source + if not source: + return + + filled_source = self._controller.fill_root_in_source(source) + clipboard = QtWidgets.QApplication.clipboard() + clipboard.setText(filled_source) + + +class InfoWidget(QtWidgets.QWidget): + """A Widget that display information about a specific version""" + def __init__(self, controller, parent): + super(InfoWidget, self).__init__(parent=parent) + + label_widget = QtWidgets.QLabel("Version Info", self) + info_text_widget = VersionTextEdit(controller, self) + info_text_widget.setReadOnly(True) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(label_widget, 0) + layout.addWidget(info_text_widget, 1) + + self._controller = controller + + self._info_text_widget = info_text_widget + self._label_widget = label_widget + + def set_selected_version_info(self, project_name, items): + if not items or not project_name: + self._info_text_widget.set_current_item() + return + first_item = next(iter(items)) + product_item = self._controller.get_product_item( + project_name, + first_item["folder_id"], + first_item["product_id"], + ) + version_id = first_item["version_id"] + version_item = None + if product_item is not None: + version_item = product_item.version_items.get(version_id) + + self._info_text_widget.set_current_item(product_item, version_item) diff --git a/openpype/tools/ayon_loader/ui/products_model.py b/openpype/tools/ayon_loader/ui/products_model.py index 4902e0aefcc..f4c3ea38f64 100644 --- a/openpype/tools/ayon_loader/ui/products_model.py +++ b/openpype/tools/ayon_loader/ui/products_model.py @@ -27,10 +27,12 @@ VERSION_STEP_ROLE = QtCore.Qt.UserRole + 18 VERSION_IN_SCENE_ROLE = QtCore.Qt.UserRole + 19 VERSION_AVAILABLE_ROLE = QtCore.Qt.UserRole + 20 +VERSION_THUMBNAIL_ID_ROLE = QtCore.Qt.UserRole + 21 class ProductsModel(QtGui.QStandardItemModel): refreshed = QtCore.Signal() + version_changed = QtCore.Signal() column_labels = [ "Product name", "Product type", @@ -92,6 +94,18 @@ def get_product_item_indexes(self): for item in self._items_by_id.values() ] + def get_product_item_by_id(self, product_id): + """ + + Args: + product_id (str): Product id. + + Returns: + Union[ProductItem, None]: Product item with version information. + """ + + return self._product_items_by_id.get(product_id) + def set_enable_grouping(self, enable_grouping): if enable_grouping is self._grouping_enabled: return @@ -204,6 +218,7 @@ def setData(self, index, value, role=None): return True item = self.itemFromIndex(index) self._set_version_data_to_product_item(item, final_version_item) + self.version_changed.emit() return True return super(ProductsModel, self).setData(index, value, role) @@ -274,6 +289,8 @@ def _set_version_data_to_product_item(self, model_item, version_item): model_item.setData(version_item.handles, VERSION_HANDLES_ROLE) model_item.setData(version_item.step, VERSION_STEP_ROLE) model_item.setData(version_item.in_scene, VERSION_IN_SCENE_ROLE) + model_item.setData( + version_item.thumbnail_id, VERSION_THUMBNAIL_ID_ROLE) def _get_product_model_item(self, product_item): model_item = self._items_by_id.get(product_item.product_id) diff --git a/openpype/tools/ayon_loader/ui/products_widget.py b/openpype/tools/ayon_loader/ui/products_widget.py index 037793c3947..ef00781164a 100644 --- a/openpype/tools/ayon_loader/ui/products_widget.py +++ b/openpype/tools/ayon_loader/ui/products_widget.py @@ -28,6 +28,7 @@ FOLDER_ID_ROLE, PRODUCT_ID_ROLE, VERSION_ID_ROLE, + VERSION_THUMBNAIL_ID_ROLE, ) from .products_delegates import VersionDelegate @@ -86,6 +87,8 @@ def sort(self, column, order=None): class ProductsWidget(QtWidgets.QWidget): merged_products_selection_changed = QtCore.Signal() + selection_changed = QtCore.Signal() + version_changed = QtCore.Signal() default_widths = ( 200, # Product name 90, # Product type @@ -148,6 +151,7 @@ def __init__(self, controller, parent): self._on_context_menu) products_view.selectionModel().selectionChanged.connect( self._on_selection_change) + products_model.version_changed.connect(self._on_version_change) controller.register_event_callback( "selection.folders.changed", @@ -177,6 +181,7 @@ def __init__(self, controller, parent): self._selected_folder_ids = set() self._selected_merged_products = [] + self._selected_versions_info = [] # Set initial state of widget self._update_folders_label_visible() @@ -205,9 +210,12 @@ def set_product_type_filter(self, product_type_filters): def set_enable_grouping(self, enable_grouping): self._products_model.set_enable_grouping(enable_grouping) - def get_merged_products_selection(self): + def get_selected_merged_products(self): return self._selected_merged_products + def get_selected_version_info(self): + return self._selected_versions_info + def _fill_version_editor(self): model = self._products_proxy_model index_queue = collections.deque() @@ -330,12 +338,35 @@ def _on_selection_change(self): model = self._products_view.model() indexes_queue = collections.deque() indexes_queue.extend(selection_model.selectedIndexes()) + + # Helper for 'version_items' to avoid duplicated items + all_product_ids = set() + # Version items contains information about selected version items + selected_versions_info = [] while indexes_queue: index = indexes_queue.popleft() if index.column() != 0: continue group_type = model.data(index, GROUP_TYPE_ROLE) + if group_type is None: + product_id = model.data(index, PRODUCT_ID_ROLE) + # Skip duplicates - when group and item are selected the item + # would be in the loop multiple times + if product_id in all_product_ids: + continue + + all_product_ids.add(product_id) + + thumbnail_id = model.data(index, VERSION_THUMBNAIL_ID_ROLE) + selected_versions_info.append({ + "folder_id": model.data(index, FOLDER_ID_ROLE), + "product_id": product_id, + "version_id": model.data(index, VERSION_ID_ROLE), + "thumbnail_id": thumbnail_id, + }) + continue + if group_type == 0: for row in range(model.rowCount(index)): child_index = model.index(row, 0, index) @@ -348,6 +379,8 @@ def _on_selection_change(self): item_folder_ids = set() for row in range(model.rowCount(index)): child_index = model.index(row, 0, index) + indexes_queue.append(child_index) + folder_id = model.data(child_index, FOLDER_ID_ROLE) item_folder_ids.add(folder_id) @@ -363,8 +396,14 @@ def _on_selection_change(self): prev_selected_merged_products = self._selected_merged_products self._selected_merged_products = selected_merged_products + self._selected_versions_info = selected_versions_info + if selected_merged_products != prev_selected_merged_products: self.merged_products_selection_changed.emit() + self.selection_changed.emit() + + def _on_version_change(self): + self._on_selection_change() def _get_options(self, action, action_item, parent): """Provides dialog to select value from loader provided options. diff --git a/openpype/tools/ayon_loader/ui/repres_widget.py b/openpype/tools/ayon_loader/ui/repres_widget.py new file mode 100644 index 00000000000..1739d727cbf --- /dev/null +++ b/openpype/tools/ayon_loader/ui/repres_widget.py @@ -0,0 +1,16 @@ +from qtpy import QtWidgets + + +class RepresentationsWidget(QtWidgets.QWidget): + def __init__(self, controller, parent): + super(RepresentationsWidget, self).__init__(parent) + + self._controller = controller + + repre_view = QtWidgets.QTreeView(self) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(repre_view, 1) + + self._repre_view = repre_view diff --git a/openpype/tools/ayon_loader/ui/window.py b/openpype/tools/ayon_loader/ui/window.py index 809ffb9149c..301ccdbd1fa 100644 --- a/openpype/tools/ayon_loader/ui/window.py +++ b/openpype/tools/ayon_loader/ui/window.py @@ -9,6 +9,8 @@ from .folders_widget import LoaderFoldersWidget from .products_widget import ProductsWidget from .product_types_widget import ProductTypesView +from .info_widget import InfoWidget +from .repres_widget import RepresentationsWidget class LoaderWindow(QtWidgets.QWidget): @@ -76,11 +78,23 @@ def __init__(self, controller=None, parent=None): products_wrap_layout.addWidget(products_inputs_widget, 0) products_wrap_layout.addWidget(products_widget, 1) + right_panel_splitter = QtWidgets.QSplitter(main_splitter) + right_panel_splitter.setOrientation(QtCore.Qt.Vertical) + + info_widget = InfoWidget(controller, right_panel_splitter) + + repre_widget = RepresentationsWidget(controller, right_panel_splitter) + + right_panel_splitter.addWidget(info_widget) + right_panel_splitter.addWidget(repre_widget) + main_splitter.addWidget(context_splitter) main_splitter.addWidget(products_wrap_widget) + main_splitter.addWidget(right_panel_splitter) - main_splitter.setStretchFactor(0, 3) - main_splitter.setStretchFactor(1, 7) + main_splitter.setStretchFactor(0, 4) + main_splitter.setStretchFactor(1, 6) + main_splitter.setStretchFactor(2, 1) main_layout = QtWidgets.QHBoxLayout(self) main_layout.addWidget(main_splitter) @@ -105,7 +119,11 @@ def __init__(self, controller=None, parent=None): products_widget.merged_products_selection_changed.connect( self._on_merged_products_selection_change ) + products_widget.selection_changed.connect( + self._on_products_selection_change + ) + self._main_splitter = main_splitter self._projects_combobox = projects_combobox self._folders_filter_input = folders_filter_input @@ -117,6 +135,8 @@ def __init__(self, controller=None, parent=None): self._product_group_checkbox = product_group_checkbox self._products_widget = products_widget + self._info_widget = info_widget + self._controller = controller self._first_show = True self._reset_on_show = True @@ -137,7 +157,8 @@ def _on_first_show(self): # self.resize(1800, 900) # else: # self.resize(1300, 700) - self.resize(1300, 700) + self.resize(1800, 900) + self._main_splitter.setSizes([250, 1000, 550]) self.setStyleSheet(load_stylesheet()) self._controller.reset() @@ -170,5 +191,12 @@ def _on_product_type_filter_change(self): ) def _on_merged_products_selection_change(self): - items = self._products_widget.get_merged_products_selection() + items = self._products_widget.get_selected_merged_products() self._folders_widget.set_merged_products_selection(items) + + def _on_products_selection_change(self): + items = self._products_widget.get_selected_version_info() + self._info_widget.set_selected_version_info( + self._projects_combobox.get_current_project_name(), + items + ) From 34d28e175dd1a8f4e4df8a105073a285a0ce62b0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 4 Oct 2023 18:47:12 +0200 Subject: [PATCH 17/69] require less to trigger action --- openpype/tools/ayon_loader/abstract.py | 4 +--- openpype/tools/ayon_loader/control.py | 8 ++------ openpype/tools/ayon_loader/ui/products_widget.py | 6 ++---- 3 files changed, 5 insertions(+), 13 deletions(-) diff --git a/openpype/tools/ayon_loader/abstract.py b/openpype/tools/ayon_loader/abstract.py index 4ded0b4c153..6411caa36e4 100644 --- a/openpype/tools/ayon_loader/abstract.py +++ b/openpype/tools/ayon_loader/abstract.py @@ -219,10 +219,8 @@ def trigger_action_item( identifier, options, project_name, - folder_ids, product_ids, - version_ids, - representation_ids, + representation_ids ): pass diff --git a/openpype/tools/ayon_loader/control.py b/openpype/tools/ayon_loader/control.py index 63f3aaccf5e..8124f251899 100644 --- a/openpype/tools/ayon_loader/control.py +++ b/openpype/tools/ayon_loader/control.py @@ -90,19 +90,15 @@ def trigger_action_item( identifier, options, project_name, - folder_ids, product_ids, - version_ids, - representation_ids, + representation_ids ): self._loader_actions_model.trigger_action_item( identifier, options, project_name, - folder_ids, product_ids, - version_ids, - representation_ids, + representation_ids ) # Selection model wrappers diff --git a/openpype/tools/ayon_loader/ui/products_widget.py b/openpype/tools/ayon_loader/ui/products_widget.py index ef00781164a..88a187bd0cd 100644 --- a/openpype/tools/ayon_loader/ui/products_widget.py +++ b/openpype/tools/ayon_loader/ui/products_widget.py @@ -326,10 +326,8 @@ def _on_context_menu(self, point): action_item.identifier, options, action_item.project_name, - action_item.folder_ids, - action_item.product_ids, - action_item.version_ids, - action_item.representation_ids, + product_ids=action_item.product_ids, + representation_ids=action_item.representation_ids, ) def _on_selection_change(self): From b3128671505d3a107cd644b81e93d17c04fd22c0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 4 Oct 2023 18:48:11 +0200 Subject: [PATCH 18/69] set selection of version ids in controller --- openpype/tools/ayon_loader/abstract.py | 8 ++++++++ openpype/tools/ayon_loader/control.py | 6 ++++++ openpype/tools/ayon_loader/models/selection.py | 3 +-- openpype/tools/ayon_loader/ui/products_widget.py | 7 ++++++- 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/openpype/tools/ayon_loader/abstract.py b/openpype/tools/ayon_loader/abstract.py index 6411caa36e4..85aa7e025ba 100644 --- a/openpype/tools/ayon_loader/abstract.py +++ b/openpype/tools/ayon_loader/abstract.py @@ -241,6 +241,14 @@ def get_selected_folder_ids(self): def set_selected_folders(self, folder_ids): pass + @abstractmethod + def get_selected_version_ids(self): + pass + + @abstractmethod + def set_selected_versions(self, version_ids): + pass + @abstractmethod def fill_root_in_source(self, source): pass diff --git a/openpype/tools/ayon_loader/control.py b/openpype/tools/ayon_loader/control.py index 8124f251899..125a74977b1 100644 --- a/openpype/tools/ayon_loader/control.py +++ b/openpype/tools/ayon_loader/control.py @@ -115,6 +115,12 @@ def get_selected_folder_ids(self): def set_selected_folders(self, folder_ids): self._selection_model.set_selected_folders(folder_ids) + def get_selected_version_ids(self): + return self._selection_model.get_selected_version_ids() + + def set_selected_versions(self, version_ids): + self._selection_model.set_selected_versions(version_ids) + def fill_root_in_source(self, source): project_name = self.get_selected_project_name() anatomy = self._get_project_anatomy(project_name) diff --git a/openpype/tools/ayon_loader/models/selection.py b/openpype/tools/ayon_loader/models/selection.py index 834144fba5f..a368d957d89 100644 --- a/openpype/tools/ayon_loader/models/selection.py +++ b/openpype/tools/ayon_loader/models/selection.py @@ -50,11 +50,10 @@ def set_selected_folders(self, folder_ids): def get_selected_version_ids(self): return self._version_ids - def set_selected_version_ids(self, version_ids): + def set_selected_versions(self, version_ids): if version_ids == self._version_ids: return - self._version_ids = version_ids self._controller.emit_event( "selection.versions.changed", diff --git a/openpype/tools/ayon_loader/ui/products_widget.py b/openpype/tools/ayon_loader/ui/products_widget.py index 88a187bd0cd..bce81ccb843 100644 --- a/openpype/tools/ayon_loader/ui/products_widget.py +++ b/openpype/tools/ayon_loader/ui/products_widget.py @@ -339,6 +339,7 @@ def _on_selection_change(self): # Helper for 'version_items' to avoid duplicated items all_product_ids = set() + selected_version_ids = set() # Version items contains information about selected version items selected_versions_info = [] while indexes_queue: @@ -356,11 +357,14 @@ def _on_selection_change(self): all_product_ids.add(product_id) + version_id = model.data(index, VERSION_ID_ROLE) + selected_version_ids.add(version_id) + thumbnail_id = model.data(index, VERSION_THUMBNAIL_ID_ROLE) selected_versions_info.append({ "folder_id": model.data(index, FOLDER_ID_ROLE), "product_id": product_id, - "version_id": model.data(index, VERSION_ID_ROLE), + "version_id": version_id, "thumbnail_id": thumbnail_id, }) continue @@ -399,6 +403,7 @@ def _on_selection_change(self): if selected_merged_products != prev_selected_merged_products: self.merged_products_selection_changed.emit() self.selection_changed.emit() + self._controller.set_selected_versions(selected_version_ids) def _on_version_change(self): self._on_selection_change() From 759b565b9d05dadd15d12015918d4fb1c1dd195c Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 4 Oct 2023 19:02:34 +0200 Subject: [PATCH 19/69] added representation widget and related logic changes --- openpype/tools/ayon_loader/abstract.py | 142 +++--- openpype/tools/ayon_loader/control.py | 19 +- openpype/tools/ayon_loader/models/actions.py | 31 +- openpype/tools/ayon_loader/models/products.py | 425 +++++++++++++----- .../tools/ayon_loader/models/selection.py | 19 + openpype/tools/ayon_loader/ui/info_widget.py | 1 - .../tools/ayon_loader/ui/repres_widget.py | 306 ++++++++++++- 7 files changed, 763 insertions(+), 180 deletions(-) diff --git a/openpype/tools/ayon_loader/abstract.py b/openpype/tools/ayon_loader/abstract.py index 85aa7e025ba..12dd887442e 100644 --- a/openpype/tools/ayon_loader/abstract.py +++ b/openpype/tools/ayon_loader/abstract.py @@ -2,6 +2,73 @@ import six +class ProductTypeItem: + def __init__(self, name, icon, checked): + self.name = name + self.icon = icon + self.checked = checked + + def to_data(self): + return { + "name": self.name, + "icon": self.icon, + "checked": self.checked, + } + + @classmethod + def from_data(cls, data): + return cls(**data) + + +class ProductItem: + def __init__( + self, + product_id, + product_type, + product_name, + product_icon, + product_type_icon, + group_name, + folder_id, + folder_label, + version_items, + ): + self.product_id = product_id + self.product_type = product_type + self.product_name = product_name + self.product_icon = product_icon + self.product_type_icon = product_type_icon + self.group_name = group_name + self.folder_id = folder_id + self.folder_label = folder_label + self.version_items = version_items + + def to_data(self): + return { + "product_id": self.product_id, + "product_type": self.product_type, + "product_name": self.product_name, + "product_icon": self.product_icon, + "product_type_icon": self.product_type_icon, + "group_name": self.group_name, + "folder_id": self.folder_id, + "folder_label": self.folder_label, + "version_items": { + version_id: version_item.to_data() + for version_id, version_item in self.version_items.items() + }, + } + + @classmethod + def from_data(cls, data): + version_items = { + version_id: VersionItem.from_data(version) + for version_id, version in data["version_items"].items() + } + data["version_items"] = version_items + return cls(**data) + + class VersionItem: def __init__( self, @@ -81,66 +148,28 @@ def from_data(cls, data): return cls(**data) -class ProductItem: +class RepreItem: def __init__( self, - product_id, - product_type, + representation_id, + representation_name, + representation_icon, product_name, - product_icon, - product_type_icon, - group_name, - folder_id, folder_label, - version_items, ): - self.product_id = product_id - self.product_type = product_type + self.representation_id = representation_id + self.representation_name = representation_name + self.representation_icon = representation_icon self.product_name = product_name - self.product_icon = product_icon - self.product_type_icon = product_type_icon - self.group_name = group_name - self.folder_id = folder_id self.folder_label = folder_label - self.version_items = version_items def to_data(self): return { - "product_id": self.product_id, - "product_type": self.product_type, + "representation_id": self.representation_id, + "representation_name": self.representation_name, + "representation_icon": self.representation_icon, "product_name": self.product_name, - "product_icon": self.product_icon, - "product_type_icon": self.product_type_icon, - "group_name": self.group_name, - "folder_id": self.folder_id, "folder_label": self.folder_label, - "version_items": { - version_id: version_item.to_data() - for version_id, version_item in self.version_items.items() - }, - } - - @classmethod - def from_data(cls, data): - version_items = { - version_id: VersionItem.from_data(version) - for version_id, version in data["version_items"].items() - } - data["version_items"] = version_items - return cls(**data) - - -class ProductTypeItem: - def __init__(self, name, icon, checked): - self.name = name - self.icon = icon - self.checked = checked - - def to_data(self): - return { - "name": self.name, - "icon": self.icon, - "checked": self.checked, } @classmethod @@ -180,12 +209,11 @@ def get_product_items(self, project_name, folder_ids, sender=None): pass @abstractmethod - def get_product_item(self, project_name, folder_id, product_id): + def get_product_item(self, project_name, product_id): """ Args: project_name (str): Project name. - folder_id (str): Folder id. product_id (str): Product id. Returns: @@ -198,6 +226,12 @@ def get_product_item(self, project_name, folder_id, product_id): def get_product_type_items(self, project_name): pass + @abstractmethod + def get_representation_items( + self, project_name, version_ids, sender=None + ): + pass + @abstractmethod def get_folder_entity(self, project_name, folder_id): pass @@ -249,6 +283,14 @@ def get_selected_version_ids(self): def set_selected_versions(self, version_ids): pass + @abstractmethod + def get_selected_representation_ids(self): + pass + + @abstractmethod + def set_selected_representations(self, repre_ids): + pass + @abstractmethod def fill_root_in_source(self, source): pass diff --git a/openpype/tools/ayon_loader/control.py b/openpype/tools/ayon_loader/control.py index 125a74977b1..e3f5297a0ca 100644 --- a/openpype/tools/ayon_loader/control.py +++ b/openpype/tools/ayon_loader/control.py @@ -65,14 +65,21 @@ def get_product_items(self, project_name, folder_ids, sender=None): return self._products_model.get_product_items( project_name, folder_ids, sender) - def get_product_item(self, project_name, folder_id, product_id): + def get_product_item(self, project_name, product_id): return self._products_model.get_product_item( - project_name, folder_id, product_id + project_name, product_id ) def get_product_type_items(self, project_name): return self._products_model.get_product_type_items(project_name) + def get_representation_items( + self, project_name, version_ids, sender=None + ): + return self._products_model.get_repre_items( + project_name, version_ids, sender + ) + def get_folder_entity(self, project_name, folder_id): self._hierarchy_model.get_folder_entity(project_name, folder_id) @@ -110,7 +117,7 @@ def set_selected_project(self, project_name): # Selection model wrappers def get_selected_folder_ids(self): - self._selection_model.get_selected_folder_ids() + return self._selection_model.get_selected_folder_ids() def set_selected_folders(self, folder_ids): self._selection_model.set_selected_folders(folder_ids) @@ -121,6 +128,12 @@ def get_selected_version_ids(self): def set_selected_versions(self, version_ids): self._selection_model.set_selected_versions(version_ids) + def get_selected_representation_ids(self): + return self._selection_model.get_selected_representation_ids() + + def set_selected_representations(self, repre_ids): + self._selection_model.set_selected_representations(repre_ids) + def fill_root_in_source(self, source): project_name = self.get_selected_project_name() anatomy = self._get_project_anatomy(project_name) diff --git a/openpype/tools/ayon_loader/models/actions.py b/openpype/tools/ayon_loader/models/actions.py index e5bac3700c1..c2201e2161e 100644 --- a/openpype/tools/ayon_loader/models/actions.py +++ b/openpype/tools/ayon_loader/models/actions.py @@ -258,9 +258,7 @@ def trigger_action_item( identifier, options, project_name, - folder_ids, product_ids, - version_ids, representation_ids ): loader = self._get_loader_by_identifier(project_name, identifier) @@ -269,9 +267,6 @@ def trigger_action_item( loader, options, project_name, - folder_ids, - product_ids, - version_ids, representation_ids, ) elif product_ids is not None: @@ -279,7 +274,6 @@ def trigger_action_item( loader, options, project_name, - folder_ids, product_ids, ) else: @@ -410,6 +404,7 @@ def _actions_sorter(self, action_item): return action_item.order, action_item.label def _contexts_for_versions(self, project_name, version_ids): + # TODO fix hero version _version_docs = get_versions(project_name, version_ids) version_docs_by_id = {} version_docs_by_product_id = collections.defaultdict(list) @@ -466,14 +461,14 @@ def _trigger_product_loader( loader, options, project_name, - folder_ids, product_ids, ): project_doc = get_project(project_name) project_doc["code"] = project_doc["data"]["code"] + product_docs = list(get_subsets(project_name, subset_ids=product_ids)) + folder_ids = {p["parent"]: p for p in product_docs} folder_docs = get_assets(project_name, asset_ids=folder_ids) folder_docs_by_id = {f["_id"]: f for f in folder_docs} - product_docs = get_subsets(project_name, subset_ids=product_ids) product_contexts = [] for product_doc in product_docs: folder_id = product_doc["parent"] @@ -495,22 +490,22 @@ def _trigger_representation_loader( loader, options, project_name, - folder_ids, - product_ids, - version_ids, representation_ids, ): project_doc = get_project(project_name) project_doc["code"] = project_doc["data"]["code"] - folder_docs = get_assets(project_name, asset_ids=folder_ids) - folder_docs_by_id = {f["_id"]: f for f in folder_docs} - product_docs = get_subsets(project_name, subset_ids=product_ids) - product_docs_by_id = {p["_id"]: p for p in product_docs} + repre_docs = list(get_representations( + project_name, representation_ids=representation_ids + )) + version_ids = {r["parent"] for r in repre_docs} version_docs = get_versions(project_name, version_ids=version_ids) version_docs_by_id = {v["_id"]: v for v in version_docs} - repre_docs = get_representations( - project_name, representation_ids=representation_ids - ) + product_ids = {v["parent"] for v in version_docs} + product_docs = get_subsets(project_name, subset_ids=product_ids) + product_docs_by_id = {p["_id"]: p for p in product_docs} + folder_ids = {p["parent"] for p in product_docs} + folder_docs = get_assets(project_name, asset_ids=folder_ids) + folder_docs_by_id = {f["_id"]: f for f in folder_docs} repre_contexts = [] for repre_doc in repre_docs: version_id = repre_doc["parent"] diff --git a/openpype/tools/ayon_loader/models/products.py b/openpype/tools/ayon_loader/models/products.py index b3b2e7af115..4ca6139066b 100644 --- a/openpype/tools/ayon_loader/models/products.py +++ b/openpype/tools/ayon_loader/models/products.py @@ -7,9 +7,10 @@ from openpype.style import get_default_entity_icon_color from openpype.tools.ayon_utils.models import NestedCacheItem from openpype.tools.ayon_loader.abstract import ( - VersionItem, - ProductItem, ProductTypeItem, + ProductItem, + VersionItem, + RepreItem, ) PRODUCTS_MODEL_SENDER = "products.model" @@ -114,31 +115,31 @@ def product_type_item_from_data(product_type_data): return ProductTypeItem(product_type_data["name"], icon, True) -class RepreItem: - def __init__(self, repre_id, version_id): - self.repre_id = repre_id - self.version_id = version_id - - @classmethod - def from_doc(cls, repre_doc): - return cls( - str(repre_doc["_id"]), - str(repre_doc["parent"]), - ) - - class ProductsModel: + lifetime = 60 # A minutes def __init__(self, controller): self._controller = controller + # Mapping helpers + # NOTE - mapping must be cleaned up with cache cleanup + self._product_item_by_id = collections.defaultdict(dict) + self._version_item_by_id = collections.defaultdict(dict) + self._product_folder_ids_mapping = collections.defaultdict(dict) + + # Cache helpers self._product_type_items_cache = NestedCacheItem( - levels=1, default_factory=list) + levels=1, default_factory=list, lifetime=self.lifetime) self._product_items_cache = NestedCacheItem( - levels=2, default_factory=dict) + levels=2, default_factory=dict, lifetime=self.lifetime) self._repre_items_cache = NestedCacheItem( - levels=3, default_factory=dict) + levels=2, default_factory=dict, lifetime=self.lifetime) def reset(self): + self._product_item_by_id.clear() + self._version_item_by_id.clear() + self._product_folder_ids_mapping.clear() + + self._product_type_items_cache.reset() self._product_items_cache.reset() self._repre_items_cache.reset() @@ -153,56 +154,261 @@ def get_product_type_items(self, project_name): return cache.get_data() def get_product_items(self, project_name, folder_ids, sender): + """ + + Returns: + list[ProductItem]: Product items. + """ + if not project_name or not folder_ids: return [] project_cache = self._product_items_cache[project_name] - caches = [] + output = [] folder_ids_to_update = set() for folder_id in folder_ids: cache = project_cache[folder_id] - caches.append(cache) - if not cache.is_valid: + if cache.is_valid: + output.extend(cache.get_data().values()) + else: folder_ids_to_update.add(folder_id) self._refresh_product_items( project_name, folder_ids_to_update, sender) - output = [] - for cache in caches: + for folder_id in folder_ids_to_update: + cache = project_cache[folder_id] output.extend(cache.get_data().values()) return output - def get_repre_items(self, project_name, version_ids): + def get_product_item(self, project_name, product_id): + if not any((project_name, product_id)): + return None + + product_items_by_id = self._product_item_by_id[project_name] + product_item = product_items_by_id.get(product_id) + if product_item is not None: + return product_item + for product_item in self._query_product_items_by_ids( + project_name, product_ids=[product_id] + ).values(): + return product_item + + def _get_product_items_by_id(self, project_name, product_ids): + product_item_by_id = self._product_item_by_id[project_name] + missing_product_ids = set() output = {} - if not version_ids: - return output - repre_ids_cache = self._repre_items_cache.get(project_name) - if repre_ids_cache is None: - return output + for product_id in product_ids: + product_item = product_item_by_id.get(product_id) + if product_item is not None: + output[product_id] = product_item + else: + missing_product_ids.add(product_id) + + output.update( + self._query_product_items_by_ids( + project_name, missing_product_ids + ) + ) + return output + def _get_version_items_by_id(self, project_name, version_ids): + version_item_by_id = self._version_item_by_id[project_name] + missing_version_ids = set() + output = {} for version_id in version_ids: - data = repre_ids_cache[version_id].get_data() - if data: - output.update(data) + version_item = version_item_by_id.get(version_id) + if version_item is not None: + output[version_id] = version_item + else: + missing_version_ids.add(version_id) + + output.update( + self._query_version_items_by_ids( + project_name, missing_version_ids + ) + ) return output - def get_product_item(self, project_name, folder_id, product_id): - if not any((project_name, folder_id, product_id)): - return None - project_cache = self._product_items_cache[project_name] - product_items_by_folder_id = project_cache[folder_id].get_data() - return product_items_by_folder_id.get(product_id) + def _create_product_items( + self, + project_name, + products, + versions, + folder_items=None, + product_type_items=None, + ): + if folder_items is None: + folder_items = self._controller.get_folder_items(project_name) - def _refresh_product_items(self, project_name, folder_ids, sender): - if not project_name or not folder_ids: - return + if product_type_items is None: + product_type_items = self.get_product_type_items(project_name) - product_type_items = self.get_product_type_items(project_name) + versions_by_product_id = collections.defaultdict(list) + for version in versions: + versions_by_product_id[version["productId"]].append(version) product_type_items_by_name = { product_type_item.name: product_type_item for product_type_item in product_type_items } + output = {} + for product in products: + product_id = product["id"] + folder_id = product["folderId"] + folder_item = folder_items.get(folder_id) + if not folder_item: + continue + versions = versions_by_product_id[product_id] + if not versions: + continue + product_item = product_item_from_entity( + product, + versions, + product_type_items_by_name, + folder_item.label, + ) + output[product_id] = product_item + return output + + def _query_product_items_by_ids( + self, + project_name, + folder_ids=None, + product_ids=None, + folder_items=None + ): + """Query product items. + + This method does get from, or store to, cache attributes. + + One of 'product_ids' or 'folder_ids' must be passed to the method. + + Args: + project_name (str): Project name. + folder_ids (Optional[Iterable[str]]): Folder ids under which are + products. + product_ids (Optional[Iterable[str]]): Product ids to use. + folder_items (Optional[Dict[str, FolderItem]]): Prepared folder + items from controller. + + Returns: + dict[str, ProductItem]: Product items by product id. + """ + + if not folder_ids and not product_ids: + return {} + + kwargs = {} + if folder_ids is not None: + kwargs["folder_ids"] = folder_ids + + if product_ids is not None: + kwargs["product_ids"] = product_ids + + products = list(ayon_api.get_products(project_name, **kwargs)) + product_ids = {product["id"] for product in products} + + versions = ayon_api.get_versions( + project_name, product_ids=product_ids + ) + + return self._create_product_items( + project_name, products, versions, folder_items=folder_items + ) + + def _query_version_items_by_ids(self, project_name, version_ids): + versions = list(ayon_api.get_versions( + project_name, version_ids=version_ids + )) + product_ids = {version["productId"] for version in versions} + products = list(ayon_api.get_products( + project_name, product_ids=product_ids + )) + product_items = self._create_product_items( + project_name, products, versions + ) + version_items = {} + for product_item in product_items.values(): + version_items.update(product_item.version_items) + return version_items + + def get_repre_items(self, project_name, version_ids, sender): + output = [] + if not any((project_name, version_ids)): + return output + + invalid_version_ids = set() + project_cache = self._repre_items_cache[project_name] + for version_id in version_ids: + version_cache = project_cache[version_id] + if version_cache.is_valid: + output.extend(version_cache.get_data().values()) + else: + invalid_version_ids.add(version_id) + + if invalid_version_ids: + self.refresh_representation_items( + project_name, invalid_version_ids, sender + ) + + for version_id in invalid_version_ids: + version_cache = project_cache[version_id] + output.extend(version_cache.get_data().values()) + + return output + + def _clear_product_version_items(self, project_name, folder_ids): + """Clear product and version items from memory. + + When products are re-queried for a folders, the old product and version + items in '_product_item_by_id' and '_version_item_by_id' should + be cleaned up from memory. And mapping in stored in + '_product_folder_ids_mapping' is not relevant either. + + Args: + project_name (str): Name of project. + folder_ids (Iterable[str]): Folder ids which are being refreshed. + """ + + project_mapping = self._product_folder_ids_mapping[project_name] + if not project_mapping: + return + + product_item_by_id = self._product_item_by_id[project_name] + version_item_by_id = self._version_item_by_id[project_name] + for folder_id in folder_ids: + product_ids = project_mapping.pop(folder_id, None) + if not product_ids: + continue + + for product_id in product_ids: + product_item = product_item_by_id.pop(product_id, None) + if product_item is None: + continue + for version_item in product_item.version_items.values(): + version_item_by_id.pop(version_item.version_id, None) + + def _refresh_product_items(self, project_name, folder_ids, sender): + """Refresh product items and store them in cache. + + Args: + project_name (str): Name of project. + folder_ids (Iterable[str]): Folder ids which are being refreshed. + sender (Union[str, None]): Who triggered the refresh. + """ + + if not project_name or not folder_ids: + return + + self._clear_product_version_items(project_name, folder_ids) + + project_mapping = self._product_folder_ids_mapping[project_name] + product_item_by_id = self._product_item_by_id[project_name] + version_item_by_id = self._version_item_by_id[project_name] + + for folder_id in folder_ids: + project_mapping[folder_id] = set() + with self._product_refresh_event_manager( project_name, folder_ids, sender ): @@ -211,36 +417,24 @@ def _refresh_product_items(self, project_name, folder_ids, sender): folder_id: {} for folder_id in folder_ids } - products = list(ayon_api.get_products( - project_name, folder_ids=folder_ids - )) - product_ids = {product["id"] for product in products} - versions = ayon_api.get_versions( - project_name, product_ids=product_ids) - - versions_by_product_id = collections.defaultdict(list) - for version in versions: - versions_by_product_id[version["productId"]].append(version) - - for product in products: - product_id = product["id"] - folder_id = product["folderId"] - folder_item = folder_items.get(folder_id) - if not folder_item: - continue - versions = versions_by_product_id[product_id] - if not versions: - continue - product_item = product_item_from_entity( - product, - versions, - product_type_items_by_name, - folder_item.label, - ) + product_items_by_id = self._query_product_items_by_ids( + project_name, + folder_ids=folder_ids, + folder_items=folder_items + ) + for product_id, product_item in product_items_by_id.items(): + folder_id = product_item.folder_id items_by_folder_id[product_item.folder_id][product_id] = ( product_item ) + project_mapping[folder_id].add(product_id) + product_item_by_id[product_id] = product_item + for version_id, version_item in ( + product_item.version_items.items() + ): + version_item_by_id[version_id] = version_item + project_cache = self._product_items_cache[project_name] for folder_id, product_items in items_by_folder_id.items(): project_cache[folder_id].update_data(product_items) @@ -253,8 +447,8 @@ def _product_refresh_event_manager( "products.refresh.started", { "project_name": project_name, - "sender": sender, "folder_ids": folder_ids, + "sender": sender, }, PRODUCTS_MODEL_SENDER ) @@ -266,65 +460,86 @@ def _product_refresh_event_manager( "products.refresh.finished", { "project_name": project_name, - "sender": sender, "folder_ids": folder_ids, + "sender": sender, }, PRODUCTS_MODEL_SENDER ) - def refresh_representations(self, project_name, version_ids): - self._controller.event_system.emit( + def refresh_representation_items( + self, project_name, version_ids, sender + ): + if not any((project_name, version_ids)): + return + self._controller.emit_event( "model.representations.refresh.started", { "project_name": project_name, "version_ids": version_ids, + "sender": sender, }, "products.model" ) failed = False try: - self._refresh_representations(project_name, version_ids) + self._refresh_representation_items(project_name, version_ids) except Exception: + # TODO add more information about failed refresh failed = True - self._controller.event_system.emit( + self._controller.emit_event( "model.representations.refresh.finished", { "project_name": project_name, "version_ids": version_ids, + "sender": sender, "failed": failed, }, "products.model" ) - def _refresh_representations(self, project_name, version_ids): - pass - # if project_name not in self._repre_items_cache: - # self._repre_items_cache[project_name] = ( - # collections.defaultdict(CacheItem.create_outdated) - # ) - # - # version_ids_to_query = set() - # repre_cache = self._repre_items_cache[project_name] - # for version_id in version_ids: - # if repre_cache[version_id].is_outdated: - # version_ids_to_query.add(version_id) - # - # if not version_ids_to_query: - # return - # - # repre_entities_by_version_id = { - # version_id: {} - # for version_id in version_ids_to_query - # } - # repre_entities = ayon_api.get_representations( - # project_name, version_ids=version_ids_to_query - # ) - # for repre_entity in repre_entities: - # repre_item = RepreItem.from_doc(repre_entity) - # repre_entities_by_version_id[repre_item.version_id][repre_item.id] = { - # repre_item - # } - # - # for version_id, repre_items in repre_docs_by_version_id.items(): - # repre_cache[version_id].update_data(repre_items) + def _refresh_representation_items(self, project_name, version_ids): + representations = list(ayon_api.get_representations( + project_name, + version_ids=version_ids, + fields=["id", "name", "versionId"] + )) + + version_items_by_id = self._get_version_items_by_id( + project_name, version_ids + ) + product_ids = { + version_item.product_id + for version_item in version_items_by_id.values() + } + product_items_by_id = self._get_product_items_by_id( + project_name, product_ids + ) + repre_icon = { + "type": "awesome-font", + "name": "fa.file-o", + "color": get_default_entity_icon_color(), + } + repre_items_by_version_id = collections.defaultdict(dict) + for representation in representations: + version_id = representation["versionId"] + version_item = version_items_by_id.get(version_id) + if version_item is None: + continue + product_item = product_items_by_id.get(version_item.product_id) + if product_item is None: + continue + repre_id = representation["id"] + repre_item = RepreItem( + repre_id, + representation["name"], + repre_icon, + product_item.product_name, + product_item.folder_label, + ) + repre_items_by_version_id[version_id][repre_id] = repre_item + + project_cache = self._repre_items_cache[project_name] + for version_id, repre_items in repre_items_by_version_id.items(): + version_cache = project_cache[version_id] + version_cache.update_data(repre_items) diff --git a/openpype/tools/ayon_loader/models/selection.py b/openpype/tools/ayon_loader/models/selection.py index a368d957d89..326ff835f60 100644 --- a/openpype/tools/ayon_loader/models/selection.py +++ b/openpype/tools/ayon_loader/models/selection.py @@ -15,6 +15,7 @@ def __init__(self, controller): self._project_name = None self._folder_ids = set() self._version_ids = set() + self._representation_ids = set() def get_selected_project_name(self): return self._project_name @@ -64,3 +65,21 @@ def set_selected_versions(self, version_ids): }, self.event_source ) + + def get_selected_representation_ids(self): + return self._representation_ids + + def set_selected_representations(self, repre_ids): + if repre_ids == self._representation_ids: + return + + self._representation_ids = repre_ids + self._controller.emit_event( + "selection.representations.changed", + { + "project_name": self._project_name, + "folder_ids": self._folder_ids, + "version_ids": self._version_ids, + "representation_ids": self._representation_ids, + } + ) diff --git a/openpype/tools/ayon_loader/ui/info_widget.py b/openpype/tools/ayon_loader/ui/info_widget.py index f696216af08..b7d1b0811fc 100644 --- a/openpype/tools/ayon_loader/ui/info_widget.py +++ b/openpype/tools/ayon_loader/ui/info_widget.py @@ -131,7 +131,6 @@ def set_selected_version_info(self, project_name, items): first_item = next(iter(items)) product_item = self._controller.get_product_item( project_name, - first_item["folder_id"], first_item["product_id"], ) version_id = first_item["version_id"] diff --git a/openpype/tools/ayon_loader/ui/repres_widget.py b/openpype/tools/ayon_loader/ui/repres_widget.py index 1739d727cbf..cedf1eb8189 100644 --- a/openpype/tools/ayon_loader/ui/repres_widget.py +++ b/openpype/tools/ayon_loader/ui/repres_widget.py @@ -1,16 +1,316 @@ -from qtpy import QtWidgets +import collections + +from qtpy import QtWidgets, QtGui, QtCore +import qtawesome + +from openpype.style import get_default_entity_icon_color +from openpype.tools.ayon_utils.widgets import get_qt_icon + +REPRESENTAION_NAME_ROLE = QtCore.Qt.UserRole + 1 +REPRESENTATION_ID_ROLE = QtCore.Qt.UserRole + 2 +PRODUCT_NAME_ROLE = QtCore.Qt.UserRole + 3 +FOLDER_LABEL_ROLE = QtCore.Qt.UserRole + 4 +GROUP_TYPE_ROLE = QtCore.Qt.UserRole + 5 + + +class RepresentationsModel(QtGui.QStandardItemModel): + refreshed = QtCore.Signal() + colums_info = [ + ("Name", 120), + ("Product name", 125), + ("Folder", 125), + # ("Active site", 85), + # ("Remote site", 85) + ] + column_labels = [label for label, _ in colums_info] + column_widths = [width for _, width in colums_info] + folder_column = column_labels.index("Product name") + + def __init__(self, controller): + super(RepresentationsModel, self).__init__() + + self.setColumnCount(len(self.column_labels)) + + for idx, label in enumerate(self.column_labels): + self.setHeaderData(idx, QtCore.Qt.Horizontal, label) + + controller.register_event_callback( + "selection.project.changed", + self._on_project_change + ) + controller.register_event_callback( + "selection.versions.changed", + self._on_version_change + ) + self._selected_project_name = None + self._selected_version_ids = None + + self._group_icon = None + + self._items_by_id = {} + self._groups_items_by_name = {} + + self._controller = controller + + def refresh(self): + repre_items = self._controller.get_representation_items( + self._selected_project_name, self._selected_version_ids + ) + self._fill_items(repre_items) + self.refreshed.emit() + + def data(self, index, role=None): + if role is None: + role = QtCore.Qt.DisplayRole + + col = index.column() + if col != 0: + if role == QtCore.Qt.DecorationRole: + return None + + if role == QtCore.Qt.DisplayRole: + if col == 1: + role = PRODUCT_NAME_ROLE + elif col == 2: + role = FOLDER_LABEL_ROLE + index = self.index(index.row(), 0, index.parent()) + return super(RepresentationsModel, self).data(index, role) + + def setData(self, index, value, role=None): + if role is None: + role = QtCore.Qt.EditRole + return super(RepresentationsModel, self).setData(index, value, role) + + def _clear_items(self): + self._items_by_id = {} + root_item = self.invisibleRootItem() + root_item.removeRows(0, root_item.rowCount()) + + def _get_repre_item(self, repre_item): + repre_id = repre_item.representation_id + repre_name = repre_item.representation_name + repre_icon = repre_item.representation_icon + item = self._items_by_id.get(repre_id) + is_new_item = False + if item is None: + is_new_item = True + item = QtGui.QStandardItem() + self._items_by_id[repre_id] = item + item.setColumnCount(self.columnCount()) + item.setEditable(False) + + icon = get_qt_icon(repre_icon) + item.setData(repre_name, QtCore.Qt.DisplayRole) + item.setData(icon, QtCore.Qt.DecorationRole) + item.setData(repre_name, REPRESENTAION_NAME_ROLE) + item.setData(repre_id, REPRESENTATION_ID_ROLE) + item.setData(repre_item.product_name, PRODUCT_NAME_ROLE) + item.setData(repre_item.folder_label, FOLDER_LABEL_ROLE) + return is_new_item, item + + def _get_group_icon(self): + if self._group_icon is None: + self._group_icon = qtawesome.icon( + "fa.folder", + color=get_default_entity_icon_color() + ) + return self._group_icon + + def _get_group_item(self, repre_name): + item = self._groups_items_by_name.get(repre_name) + if item is not None: + return False, item + + # TODO add color + item = QtGui.QStandardItem() + item.setColumnCount(self.columnCount()) + item.setData(repre_name, QtCore.Qt.DisplayRole) + item.setData(self._get_group_icon(), QtCore.Qt.DecorationRole) + item.setData(0, GROUP_TYPE_ROLE) + item.setEditable(False) + self._groups_items_by_name[repre_name] = item + return True, item + + def _fill_items(self, repre_items): + items_to_remove = set(self._items_by_id.keys()) + repre_items_by_name = collections.defaultdict(list) + for repre_item in repre_items: + items_to_remove.discard(repre_item.representation_id) + repre_name = repre_item.representation_name + repre_items_by_name[repre_name].append(repre_item) + + root_item = self.invisibleRootItem() + for repre_id in items_to_remove: + item = self._items_by_id.pop(repre_id) + parent_item = item.parent() + if parent_item is None: + parent_item = root_item + parent_item.removeRow(item.row()) + + group_names = set() + new_root_items = [] + for repre_name, repre_name_items in repre_items_by_name.items(): + group_item = None + parent_is_group = False + if len(repre_name_items) > 1: + group_names.add(repre_name) + is_new_group, group_item = self._get_group_item(repre_name) + if is_new_group: + new_root_items.append(group_item) + parent_is_group = True + + new_group_items = [] + for repre_item in repre_name_items: + is_new_item, item = self._get_repre_item(repre_item) + item_parent = item.parent() + if item_parent is None: + item_parent = root_item + + if not is_new_item: + if parent_is_group: + if item_parent is group_item: + continue + elif item_parent is root_item: + continue + item_parent.takeRow(item.row()) + is_new_item = True + + if is_new_item: + new_group_items.append(item) + + if not new_group_items: + continue + + if group_item is not None: + group_item.appendRows(new_group_items) + else: + new_root_items.extend(new_group_items) + + if new_root_items: + root_item.appendRows(new_root_items) + + for group_name in set(self._groups_items_by_name) - group_names: + item = self._groups_items_by_name.pop(group_name) + parent_item = item.parent() + if parent_item is None: + parent_item = root_item + parent_item.removeRow(item.row()) + + def _on_project_change(self, event): + self._selected_project_name = event["project_name"] + + def _on_version_change(self, event): + self._selected_version_ids = event["version_ids"] + self.refresh() class RepresentationsWidget(QtWidgets.QWidget): def __init__(self, controller, parent): super(RepresentationsWidget, self).__init__(parent) - self._controller = controller - repre_view = QtWidgets.QTreeView(self) + repre_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + repre_view.setSortingEnabled(True) + repre_view.setAlternatingRowColors(True) + + repre_model = RepresentationsModel(controller) + repre_proxy_model = QtCore.QSortFilterProxyModel() + repre_proxy_model.setSourceModel(repre_model) + repre_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) + repre_view.setModel(repre_proxy_model) + + for idx, width in enumerate(repre_model.column_widths): + repre_view.setColumnWidth(idx, width) main_layout = QtWidgets.QVBoxLayout(self) main_layout.setContentsMargins(0, 0, 0, 0) main_layout.addWidget(repre_view, 1) + repre_view.customContextMenuRequested.connect( + self._on_context_menu) + repre_view.selectionModel().selectionChanged.connect( + self._on_selection_change) + repre_model.refreshed.connect(self._on_model_refresh) + + controller.register_event_callback( + "selection.project.changed", + self._on_project_change + ) + controller.register_event_callback( + "selection.folders.changed", + self._on_folder_change + ) + + self._controller = controller + self._selected_project_name = None + self._selected_multiple_folders = None + self._repre_view = repre_view + self._repre_model = repre_model + self._repre_proxy_model = repre_proxy_model + + self._set_multiple_folders_selected(False) + + def refresh(self): + self._repre_model.refresh() + + def _on_folder_change(self, event): + self._set_multiple_folders_selected(len(event["folder_ids"]) > 1) + + def _on_project_change(self, event): + self._selected_project_name = event["project_name"] + + def _set_multiple_folders_selected(self, selected_multiple_folders): + if selected_multiple_folders == self._selected_multiple_folders: + return + self._selected_multiple_folders = selected_multiple_folders + self._repre_view.setColumnHidden( + self._repre_model.folder_column, + not self._selected_multiple_folders + ) + + def _on_model_refresh(self): + self._repre_proxy_model.sort(0) + + def _get_selected_repre_indexes(self): + selection_model = self._repre_view.selectionModel() + model = self._repre_view.model() + indexes_queue = collections.deque() + indexes_queue.extend(selection_model.selectedIndexes()) + + selected_indexes = [] + while indexes_queue: + index = indexes_queue.popleft() + if index.column() != 0: + continue + + group_type = model.data(index, GROUP_TYPE_ROLE) + if group_type is None: + selected_indexes.append(index) + + elif group_type == 0: + for row in range(model.rowCount(index)): + child_index = model.index(row, 0, index) + indexes_queue.append(child_index) + + return selected_indexes + + def _get_selected_repre_ids(self): + repre_ids = { + index.data(REPRESENTATION_ID_ROLE) + for index in self._get_selected_repre_indexes() + } + repre_ids.discard(None) + return repre_ids + + def _on_selection_change(self): + selected_repre_ids = self._get_selected_repre_ids() + self._controller.set_selected_representations(selected_repre_ids) + + def _on_context_menu(self, pos): + # TODO implement + repre_ids = self._get_selected_repre_ids() + action_items = self._controller.get_representations_action_items( + self._selected_project_name, repre_ids + ) + print(action_items) From 155322f64f48ad1671c04c7d32069a4ca5e968c0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 5 Oct 2023 12:09:32 +0200 Subject: [PATCH 20/69] implemented actions in representations widget --- openpype/tools/ayon_loader/models/actions.py | 218 ++++++++++++------ .../tools/ayon_loader/ui/actions_utils.py | 118 ++++++++++ .../tools/ayon_loader/ui/products_widget.py | 118 +--------- .../tools/ayon_loader/ui/repres_widget.py | 24 +- 4 files changed, 301 insertions(+), 177 deletions(-) create mode 100644 openpype/tools/ayon_loader/ui/actions_utils.py diff --git a/openpype/tools/ayon_loader/models/actions.py b/openpype/tools/ayon_loader/models/actions.py index c2201e2161e..aa04545aaed 100644 --- a/openpype/tools/ayon_loader/models/actions.py +++ b/openpype/tools/ayon_loader/models/actions.py @@ -183,75 +183,34 @@ def reset(self): self._repre_loaders.reset() def get_versions_action_items(self, project_name, version_ids): - action_items = [] - if not project_name or not version_ids: - return action_items - - product_loaders, repre_loaders = self._get_loaders(project_name) ( product_context_by_id, repre_context_by_id - ) = self._contexts_for_versions(project_name, version_ids) - - repre_contexts = list(repre_context_by_id.values()) - repre_ids = set(repre_context_by_id.keys()) - repre_version_ids = set() - repre_product_ids = set() - repre_folder_ids = set() - for repre_context in repre_context_by_id.values(): - repre_product_ids.add(repre_context["subset"]["_id"]) - repre_version_ids.add(repre_context["version"]["_id"]) - repre_folder_ids.add(repre_context["asset"]["_id"]) - - action_items = [] - for loader in repre_loaders: - if not repre_contexts: - break - - # # do not allow download whole repre, select specific repre - # if tools_lib.is_sync_loader(loader): - # continue - - filtered_repre_contexts = filter_repre_contexts_by_loader( - repre_contexts, loader) - if len(filtered_repre_contexts) != len(repre_contexts): - continue - - item = self._create_loader_action_item( - loader, - repre_contexts, - project_name=project_name, - folder_ids=repre_folder_ids, - product_ids=repre_product_ids, - version_ids=repre_version_ids, - representation_ids=repre_ids - ) - action_items.append(item) - - # Subset Loaders. - product_ids = set(product_context_by_id.keys()) - product_folder_ids = set() - for product_context in product_context_by_id.values(): - product_folder_ids.add(product_context["asset"]["_id"]) - - product_contexts = list(product_context_by_id.values()) - for loader in product_loaders: - item = self._create_loader_action_item( - loader, - product_contexts, - project_name=project_name, - folder_ids=product_folder_ids, - product_ids=product_ids, - ) - action_items.append(item) - - action_items.sort(key=self._actions_sorter) - return action_items + ) = self._contexts_for_versions( + project_name, + version_ids + ) + return self._get_action_items_for_contexts( + project_name, + product_context_by_id, + repre_context_by_id + ) def get_representations_action_items( self, project_name, representation_ids ): - return [] + ( + product_context_by_id, + repre_context_by_id + ) = self._contexts_for_representations( + project_name, + representation_ids + ) + return self._get_action_items_for_contexts( + project_name, + product_context_by_id, + repre_context_by_id + ) def trigger_action_item( self, @@ -405,6 +364,11 @@ def _actions_sorter(self, action_item): def _contexts_for_versions(self, project_name, version_ids): # TODO fix hero version + product_context_by_id = {} + repre_context_by_id = {} + if not project_name and not version_ids: + return product_context_by_id, repre_context_by_id + _version_docs = get_versions(project_name, version_ids) version_docs_by_id = {} version_docs_by_product_id = collections.defaultdict(list) @@ -425,8 +389,6 @@ def _contexts_for_versions(self, project_name, version_ids): project_doc = get_project(project_name) project_doc["code"] = project_doc["data"]["code"] - repre_context_by_id = {} - product_context_by_id = {} for product_id, product_doc in product_docs_by_id.items(): folder_id = product_doc["parent"] folder_doc = folder_docs_by_id[folder_id] @@ -456,6 +418,130 @@ def _contexts_for_versions(self, project_name, version_ids): return product_context_by_id, repre_context_by_id + def _contexts_for_representations(self, project_name, repre_ids): + product_context_by_id = {} + repre_context_by_id = {} + if not project_name and not repre_ids: + return product_context_by_id, repre_context_by_id + + repre_docs = list(get_representations( + project_name, representation_ids=repre_ids + )) + version_ids = {r["parent"] for r in repre_docs} + version_docs = get_versions(project_name, version_ids=version_ids) + version_docs_by_id = { + v["_id"]: v for v in version_docs + } + + product_ids = {v["parent"] for v in version_docs_by_id.values()} + product_docs = get_subsets(project_name, subset_ids=product_ids) + product_docs_by_id = { + p["_id"]: p for p in product_docs + } + + folder_ids = {p["parent"] for p in product_docs_by_id.values()} + folder_docs = get_assets(project_name, asset_ids=folder_ids) + folder_docs_by_id = { + f["_id"]: f for f in folder_docs + } + + project_doc = get_project(project_name) + project_doc["code"] = project_doc["data"]["code"] + + for product_id, product_doc in product_docs_by_id.items(): + folder_id = product_doc["parent"] + folder_doc = folder_docs_by_id[folder_id] + product_context_by_id[product_id] = { + "project": project_doc, + "asset": folder_doc, + "subset": product_doc, + } + + repre_docs = get_representations( + project_name, version_ids=version_ids) + for repre_doc in repre_docs: + version_id = repre_doc["parent"] + version_doc = version_docs_by_id[version_id] + product_id = version_doc["parent"] + product_doc = product_docs_by_id[product_id] + folder_id = product_doc["parent"] + folder_doc = folder_docs_by_id[folder_id] + + repre_context_by_id[repre_doc["_id"]] = { + "project": project_doc, + "asset": folder_doc, + "subset": product_doc, + "version": version_doc, + "representation": repre_doc, + } + return product_context_by_id, repre_context_by_id + + def _get_action_items_for_contexts( + self, + project_name, + product_context_by_id, + repre_context_by_id + ): + action_items = [] + if not product_context_by_id and not repre_context_by_id: + return action_items + + product_loaders, repre_loaders = self._get_loaders(project_name) + + repre_contexts = list(repre_context_by_id.values()) + repre_ids = set(repre_context_by_id.keys()) + repre_version_ids = set() + repre_product_ids = set() + repre_folder_ids = set() + for repre_context in repre_context_by_id.values(): + repre_product_ids.add(repre_context["subset"]["_id"]) + repre_version_ids.add(repre_context["version"]["_id"]) + repre_folder_ids.add(repre_context["asset"]["_id"]) + + for loader in repre_loaders: + if not repre_contexts: + break + + # # do not allow download whole repre, select specific repre + # if tools_lib.is_sync_loader(loader): + # continue + + filtered_repre_contexts = filter_repre_contexts_by_loader( + repre_contexts, loader) + if len(filtered_repre_contexts) != len(repre_contexts): + continue + + item = self._create_loader_action_item( + loader, + repre_contexts, + project_name=project_name, + folder_ids=repre_folder_ids, + product_ids=repre_product_ids, + version_ids=repre_version_ids, + representation_ids=repre_ids + ) + action_items.append(item) + + # Subset Loaders. + product_ids = set(product_context_by_id.keys()) + product_folder_ids = set() + for product_context in product_context_by_id.values(): + product_folder_ids.add(product_context["asset"]["_id"]) + + product_contexts = list(product_context_by_id.values()) + for loader in product_loaders: + item = self._create_loader_action_item( + loader, + product_contexts, + project_name=project_name, + folder_ids=product_folder_ids, + product_ids=product_ids, + ) + action_items.append(item) + + action_items.sort(key=self._actions_sorter) + return action_items + def _trigger_product_loader( self, loader, @@ -500,10 +586,10 @@ def _trigger_representation_loader( version_ids = {r["parent"] for r in repre_docs} version_docs = get_versions(project_name, version_ids=version_ids) version_docs_by_id = {v["_id"]: v for v in version_docs} - product_ids = {v["parent"] for v in version_docs} + product_ids = {v["parent"] for v in version_docs_by_id.values()} product_docs = get_subsets(project_name, subset_ids=product_ids) product_docs_by_id = {p["_id"]: p for p in product_docs} - folder_ids = {p["parent"] for p in product_docs} + folder_ids = {p["parent"] for p in product_docs_by_id.values()} folder_docs = get_assets(project_name, asset_ids=folder_ids) folder_docs_by_id = {f["_id"]: f for f in folder_docs} repre_contexts = [] diff --git a/openpype/tools/ayon_loader/ui/actions_utils.py b/openpype/tools/ayon_loader/ui/actions_utils.py new file mode 100644 index 00000000000..a269b643dc3 --- /dev/null +++ b/openpype/tools/ayon_loader/ui/actions_utils.py @@ -0,0 +1,118 @@ +import uuid + +from qtpy import QtWidgets, QtGui +import qtawesome + +from openpype.lib.attribute_definitions import AbstractAttrDef +from openpype.tools.attribute_defs import AttributeDefinitionsDialog +from openpype.tools.utils.widgets import ( + OptionalMenu, + OptionalAction, + OptionDialog, +) +from openpype.tools.ayon_utils.widgets import get_qt_icon + + +def show_actions_menu(action_items, global_point, one_item_selected, parent): + selected_action_item = None + selected_options = None + + if not action_items: + menu = QtWidgets.QMenu(parent) + action = _get_no_loader_action(menu, one_item_selected) + menu.addAction(action) + menu.exec_(global_point) + return selected_action_item, selected_options + + menu = OptionalMenu(parent) + + action_items_by_id = {} + for action_item in action_items: + item_id = uuid.uuid4().hex + action_items_by_id[item_id] = action_item + item_options = action_item.options + icon = get_qt_icon(action_item.icon) + use_option = bool(item_options) + action = OptionalAction( + action_item.label, + icon, + use_option, + menu + ) + if use_option: + # Add option box tip + action.set_option_tip(item_options) + + tip = action_item.tooltip + if tip: + action.setToolTip(tip) + action.setStatusTip(tip) + + action.setData(item_id) + + menu.addAction(action) + + action = menu.exec_(global_point) + if action is not None: + item_id = action.data() + selected_action_item = action_items_by_id.get(item_id) + + if selected_action_item is not None: + selected_options = _get_options(action, selected_action_item, parent) + + return selected_action_item, selected_options + + +def _get_options(action, action_item, parent): + """Provides dialog to select value from loader provided options. + + Loader can provide static or dynamically created options based on + AttributeDefinitions, and for backwards compatibility qargparse. + + Args: + action (OptionalAction) - Action object in menu. + action_item (ActionItem) - Action item with context information. + parent (QtCore.QObject) - Parent object for dialog. + + Returns: + Union[dict[str, Any], None]: Selected value from attributes or + 'None' if dialog was cancelled. + """ + + # Pop option dialog + options = action_item.options + if not getattr(action, "optioned", False) or not options: + return {} + + if isinstance(options[0], AbstractAttrDef): + qargparse_options = False + dialog = AttributeDefinitionsDialog(options, parent) + else: + qargparse_options = True + dialog = OptionDialog(parent) + dialog.create(options) + + dialog.setWindowTitle(action.label + " Options") + + if not dialog.exec_(): + return None + + # Get option + if qargparse_options: + return dialog.parse() + return dialog.get_values() + + +def _get_no_loader_action(menu, one_item_selected): + """Creates dummy no loader option in 'menu'""" + + if one_item_selected: + submsg = "this version." + else: + submsg = "your selection." + msg = "No compatible loaders for {}".format(submsg) + icon = qtawesome.icon( + "fa.exclamation", + color=QtGui.QColor(255, 51, 0) + ) + return QtWidgets.QAction(icon, ("*" + msg), menu) diff --git a/openpype/tools/ayon_loader/ui/products_widget.py b/openpype/tools/ayon_loader/ui/products_widget.py index bce81ccb843..73689220991 100644 --- a/openpype/tools/ayon_loader/ui/products_widget.py +++ b/openpype/tools/ayon_loader/ui/products_widget.py @@ -1,23 +1,12 @@ import collections -import uuid -import qtawesome -from qtpy import QtWidgets, QtCore, QtGui +from qtpy import QtWidgets, QtCore - -from openpype.lib.attribute_definitions import AbstractAttrDef -from openpype.tools.attribute_defs import AttributeDefinitionsDialog from openpype.tools.utils import ( RecursiveSortFilterProxyModel, DeselectableTreeView, ) from openpype.tools.utils.delegates import PrettyTimeDelegate -from openpype.tools.utils.widgets import ( - OptionalMenu, - OptionalAction, - OptionDialog, -) -from openpype.tools.ayon_utils.widgets import get_qt_icon from .products_model import ( ProductsModel, @@ -31,6 +20,7 @@ VERSION_THUMBNAIL_ID_ROLE, ) from .products_delegates import VersionDelegate +from .actions_utils import show_actions_menu class ProductsProxyModel(RecursiveSortFilterProxyModel): @@ -276,52 +266,17 @@ def _on_context_menu(self, point): # Prepare global point where to show the menu global_point = self._products_view.mapToGlobal(point) - if not action_items: - menu = QtWidgets.QMenu(self) - action = self._get_no_loader_action(menu, len(version_ids) == 1) - menu.addAction(action) - menu.exec_(global_point) - return - menu = OptionalMenu(self) - - action_items_by_id = {} - for action_item in action_items: - item_id = uuid.uuid4().hex - action_items_by_id[item_id] = action_item - options = action_item.options - icon = get_qt_icon(action_item.icon) - use_option = bool(options) - action = OptionalAction( - action_item.label, - icon, - use_option, - menu - ) - if use_option: - # Add option box tip - action.set_option_tip(options) - - tip = action_item.tooltip - if tip: - action.setToolTip(tip) - action.setStatusTip(tip) - - action.setData(item_id) - - menu.addAction(action) - - action = menu.exec_(global_point) - action_item = None - if action is not None: - item_id = action.data() - action_item = action_items_by_id.get(item_id) - if action_item is None: + result = show_actions_menu( + action_items, + global_point, + len(version_ids) == 1, + self + ) + action_item, options = result + if action_item is None or options is None: return - options = self._get_options(action, action_item, self) - if options is None: - return self._controller.trigger_action_item( action_item.identifier, options, @@ -408,59 +363,6 @@ def _on_selection_change(self): def _on_version_change(self): self._on_selection_change() - def _get_options(self, action, action_item, parent): - """Provides dialog to select value from loader provided options. - - Loader can provide static or dynamically created options based on - AttributeDefinitions, and for backwards compatibility qargparse. - - Args: - action (OptionalAction) - Action object in menu. - action_item (ActionItem) - Action item with context information. - parent (QtCore.QObject) - Parent object for dialog. - - Returns: - Union[dict[str, Any], None]: Selected value from attributes or - 'None' if dialog was cancelled. - """ - - # Pop option dialog - options = action_item.options - if not getattr(action, "optioned", False) or not options: - return {} - - if isinstance(options[0], AbstractAttrDef): - qargparse_options = False - dialog = AttributeDefinitionsDialog(options, parent) - else: - qargparse_options = True - dialog = OptionDialog(parent) - dialog.create(options) - - dialog.setWindowTitle(action.label + " Options") - - if not dialog.exec_(): - return None - - # Get option - if qargparse_options: - return dialog.parse() - return dialog.get_values() - - def _get_no_loader_action(self, menu, one_item_selected): - """Creates dummy no loader option in 'menu'""" - - if one_item_selected: - submsg = "this version." - else: - submsg = "your selection." - msg = "No compatible loaders for {}".format(submsg) - icon = qtawesome.icon( - "fa.exclamation", - color=QtGui.QColor(255, 51, 0) - ) - return QtWidgets.QAction(icon, ("*" + msg), menu) - def _on_folders_selection_change(self, event): self._selected_project_name = event["project_name"] self._selected_folder_ids = event["folder_ids"] diff --git a/openpype/tools/ayon_loader/ui/repres_widget.py b/openpype/tools/ayon_loader/ui/repres_widget.py index cedf1eb8189..8a4f02aadd1 100644 --- a/openpype/tools/ayon_loader/ui/repres_widget.py +++ b/openpype/tools/ayon_loader/ui/repres_widget.py @@ -6,6 +6,8 @@ from openpype.style import get_default_entity_icon_color from openpype.tools.ayon_utils.widgets import get_qt_icon +from .actions_utils import show_actions_menu + REPRESENTAION_NAME_ROLE = QtCore.Qt.UserRole + 1 REPRESENTATION_ID_ROLE = QtCore.Qt.UserRole + 2 PRODUCT_NAME_ROLE = QtCore.Qt.UserRole + 3 @@ -307,10 +309,26 @@ def _on_selection_change(self): selected_repre_ids = self._get_selected_repre_ids() self._controller.set_selected_representations(selected_repre_ids) - def _on_context_menu(self, pos): - # TODO implement + def _on_context_menu(self, point): repre_ids = self._get_selected_repre_ids() action_items = self._controller.get_representations_action_items( self._selected_project_name, repre_ids ) - print(action_items) + global_point = self._repre_view.mapToGlobal(point) + result = show_actions_menu( + action_items, + global_point, + len(repre_ids) == 1, + self + ) + action_item, options = result + if action_item is None or options is None: + return + + self._controller.trigger_action_item( + action_item.identifier, + options, + action_item.project_name, + product_ids=action_item.product_ids, + representation_ids=action_item.representation_ids, + ) From 37c6b47a9b706df0659ece0dd74f63eeb3e9e2ca Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 5 Oct 2023 16:49:02 +0200 Subject: [PATCH 21/69] handle load error --- openpype/tools/ayon_loader/models/actions.py | 155 +++++++++++++++++-- openpype/tools/ayon_loader/ui/window.py | 77 ++++++++- 2 files changed, 221 insertions(+), 11 deletions(-) diff --git a/openpype/tools/ayon_loader/models/actions.py b/openpype/tools/ayon_loader/models/actions.py index aa04545aaed..216096f79e5 100644 --- a/openpype/tools/ayon_loader/models/actions.py +++ b/openpype/tools/ayon_loader/models/actions.py @@ -1,6 +1,9 @@ +import sys +import traceback import inspect import copy import collections +import uuid from abc import ABCMeta, abstractmethod import six @@ -25,6 +28,8 @@ ) from openpype.tools.ayon_utils.models import NestedCacheItem +ACTIONS_MODEL_SENDER = "actions.model" + @six.add_metaclass(ABCMeta) class BaseActionItem(object): @@ -220,16 +225,25 @@ def trigger_action_item( product_ids, representation_ids ): + event_data = { + "identifier": identifier, + "id": uuid.uuid4().hex, + } + self._controller.emit_event( + "load.started", + event_data, + ACTIONS_MODEL_SENDER, + ) loader = self._get_loader_by_identifier(project_name, identifier) if representation_ids is not None: - self._trigger_representation_loader( + error_info = self._trigger_representation_loader( loader, options, project_name, representation_ids, ) elif product_ids is not None: - self._trigger_product_loader( + error_info = self._trigger_product_loader( loader, options, project_name, @@ -239,6 +253,13 @@ def trigger_action_item( raise NotImplementedError( "Invalid arguments to trigger action item") + event_data["error_info"] = error_info + self._controller.emit_event( + "load.finished", + event_data, + ACTIONS_MODEL_SENDER, + ) + def _get_loader_label(self, loader, representation=None): """Pull label info from loader class""" label = getattr(loader, "label", None) @@ -565,11 +586,9 @@ def _trigger_product_loader( "subset": product_doc, }) - if loader.is_multiple_contexts_compatible: - load_with_subset_contexts(loader, product_contexts, options) - else: - for product_context in product_contexts: - load_with_subset_context(loader, product_context, options) + return self._load_products_by_loader( + loader, product_contexts, options + ) def _trigger_representation_loader( self, @@ -608,5 +627,123 @@ def _trigger_representation_loader( "representation": repre_doc, }) - for repre_context in repre_contexts: - load_with_repre_context(loader, repre_context, options=options) + return self._load_representations_by_loader( + loader, repre_contexts, options + ) + + def _load_representations_by_loader(self, loader, repre_contexts, options): + """Loops through list of repre_contexts and loads them with one loader + + Args: + loader (LoaderPlugin): Loader plugin to use. + repre_contexts (list[dict]): Full info about selected representations, + containing repre, version, subset, asset and project documents. + options (dict): Data from options. + """ + + error_info = [] + for repre_context in repre_contexts.values(): + version_doc = repre_context["version"] + if version_doc["type"] == "hero_version": + version_name = "Hero" + else: + version_name = version_doc.get("name") + try: + load_with_repre_context( + loader, + repre_context, + options=options + ) + + except IncompatibleLoaderError as exc: + print(exc) + error_info.append(( + "Incompatible Loader", + None, + repre_context["representation"]["name"], + repre_context["subset"]["name"], + version_name + )) + + except Exception as exc: + formatted_traceback = None + if not isinstance(exc, LoadError): + 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"], + version_name + )) + return error_info + + def _load_products_by_loader(self, loader, version_contexts, options): + """Triggers load with SubsetLoader type of loaders + + Args: + loader (SubsetLoder): + version_contexts (list): + options (dict): + """ + + error_info = [] + if loader.is_multiple_contexts_compatible: + subset_names = [] + for context in version_contexts: + subset_name = context.get("subset", {}).get("name") or "N/A" + subset_names.append(subset_name) + try: + load_with_subset_contexts( + loader, + version_contexts, + options=options + ) + + except Exception as exc: + formatted_traceback = None + if not isinstance(exc, LoadError): + 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 version_context in version_contexts: + subset_name = ( + version_context.get("subset", {}).get("name") or "N/A" + ) + try: + load_with_subset_context( + loader, + version_context, + options=options + ) + + except Exception as exc: + formatted_traceback = None + if not isinstance(exc, LoadError): + 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, + subset_name, + None + )) + + return error_info diff --git a/openpype/tools/ayon_loader/ui/window.py b/openpype/tools/ayon_loader/ui/window.py index 301ccdbd1fa..b3b436b4fb0 100644 --- a/openpype/tools/ayon_loader/ui/window.py +++ b/openpype/tools/ayon_loader/ui/window.py @@ -2,7 +2,7 @@ from openpype.resources import get_openpype_icon_filepath from openpype.style import load_stylesheet -from openpype.tools.utils import PlaceholderLineEdit +from openpype.tools.utils import PlaceholderLineEdit, ErrorMessageBox from openpype.tools.ayon_utils.widgets import ProjectsCombobox from openpype.tools.ayon_loader.control import LoaderController @@ -13,6 +13,67 @@ from .repres_widget import RepresentationsWidget +class LoadErrorMessageBox(ErrorMessageBox): + def __init__(self, messages, parent=None): + self._messages = messages + super(LoadErrorMessageBox, self).__init__("Loading failed", parent) + + def _create_top_widget(self, parent_widget): + label_widget = QtWidgets.QLabel(parent_widget) + label_widget.setText( + "Failed to load items" + ) + return label_widget + + def _get_report_data(self): + report_data = [] + for exc_msg, tb_text, repre, product, version in self._messages: + report_message = ( + "During load error happened on Product: \"{product}\"" + " Representation: \"{repre}\" Version: {version}" + "\n\nError message: {message}" + ).format( + product=product, + repre=repre, + version=version, + message=exc_msg + ) + if tb_text: + report_message += "\n\n{}".format(tb_text) + report_data.append(report_message) + return report_data + + def _create_content(self, content_layout): + item_name_template = ( + "Product: {}
" + "Version: {}
" + "Representation: {}
" + ) + exc_msg_template = "{}" + + for exc_msg, tb_text, repre, product, version in self._messages: + line = self._create_line() + content_layout.addWidget(line) + + item_name = item_name_template.format(product, version, repre) + item_name_widget = QtWidgets.QLabel( + item_name.replace("\n", "
"), self + ) + item_name_widget.setWordWrap(True) + content_layout.addWidget(item_name_widget) + + exc_msg = exc_msg_template.format(exc_msg.replace("\n", "
")) + message_label_widget = QtWidgets.QLabel(exc_msg, self) + message_label_widget.setWordWrap(True) + content_layout.addWidget(message_label_widget) + + if tb_text: + line = self._create_line() + tb_widget = self._create_traceback_widget(tb_text, self) + content_layout.addWidget(line) + content_layout.addWidget(tb_widget) + + class LoaderWindow(QtWidgets.QWidget): def __init__(self, controller=None, parent=None): super(LoaderWindow, self).__init__(parent) @@ -55,7 +116,7 @@ def __init__(self, controller=None, parent=None): context_splitter.setStretchFactor(0, 65) context_splitter.setStretchFactor(1, 35) - # Subset + version selection item + # Product + version selection item products_wrap_widget = QtWidgets.QWidget(main_splitter) products_inputs_widget = QtWidgets.QWidget(products_wrap_widget) @@ -122,6 +183,10 @@ def __init__(self, controller=None, parent=None): products_widget.selection_changed.connect( self._on_products_selection_change ) + controller.register_event_callback( + "load.finished", + self._on_load_finished, + ) self._main_splitter = main_splitter self._projects_combobox = projects_combobox @@ -200,3 +265,11 @@ def _on_products_selection_change(self): self._projects_combobox.get_current_project_name(), items ) + + def _on_load_finished(self, event): + error_info = event["error_info"] + if not error_info: + return + + box = LoadErrorMessageBox(error_info, self) + box.show() From 5d9a7bd9d2041583a541ba039f10d954fe072c69 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 5 Oct 2023 17:50:58 +0200 Subject: [PATCH 22/69] use versions for subset loader --- openpype/tools/ayon_loader/abstract.py | 2 +- openpype/tools/ayon_loader/control.py | 4 +- openpype/tools/ayon_loader/models/actions.py | 77 ++++++++++++------- .../tools/ayon_loader/ui/products_widget.py | 2 +- 4 files changed, 52 insertions(+), 33 deletions(-) diff --git a/openpype/tools/ayon_loader/abstract.py b/openpype/tools/ayon_loader/abstract.py index 12dd887442e..a1a062dc182 100644 --- a/openpype/tools/ayon_loader/abstract.py +++ b/openpype/tools/ayon_loader/abstract.py @@ -253,7 +253,7 @@ def trigger_action_item( identifier, options, project_name, - product_ids, + version_ids, representation_ids ): pass diff --git a/openpype/tools/ayon_loader/control.py b/openpype/tools/ayon_loader/control.py index e3f5297a0ca..6c86c8f6aa0 100644 --- a/openpype/tools/ayon_loader/control.py +++ b/openpype/tools/ayon_loader/control.py @@ -97,14 +97,14 @@ def trigger_action_item( identifier, options, project_name, - product_ids, + version_ids, representation_ids ): self._loader_actions_model.trigger_action_item( identifier, options, project_name, - product_ids, + version_ids, representation_ids ) diff --git a/openpype/tools/ayon_loader/models/actions.py b/openpype/tools/ayon_loader/models/actions.py index 216096f79e5..0c9e2f1e3bd 100644 --- a/openpype/tools/ayon_loader/models/actions.py +++ b/openpype/tools/ayon_loader/models/actions.py @@ -189,7 +189,7 @@ def reset(self): def get_versions_action_items(self, project_name, version_ids): ( - product_context_by_id, + version_context_by_id, repre_context_by_id ) = self._contexts_for_versions( project_name, @@ -197,7 +197,7 @@ def get_versions_action_items(self, project_name, version_ids): ) return self._get_action_items_for_contexts( project_name, - product_context_by_id, + version_context_by_id, repre_context_by_id ) @@ -222,7 +222,7 @@ def trigger_action_item( identifier, options, project_name, - product_ids, + version_ids, representation_ids ): event_data = { @@ -242,12 +242,12 @@ def trigger_action_item( project_name, representation_ids, ) - elif product_ids is not None: - error_info = self._trigger_product_loader( + elif version_ids is not None: + error_info = self._trigger_version_loader( loader, options, project_name, - product_ids, + version_ids, ) else: raise NotImplementedError( @@ -385,15 +385,15 @@ def _actions_sorter(self, action_item): def _contexts_for_versions(self, project_name, version_ids): # TODO fix hero version - product_context_by_id = {} + version_context_by_id = {} repre_context_by_id = {} if not project_name and not version_ids: - return product_context_by_id, repre_context_by_id + return version_context_by_id, repre_context_by_id - _version_docs = get_versions(project_name, version_ids) + version_docs = list(get_versions(project_name, version_ids)) version_docs_by_id = {} version_docs_by_product_id = collections.defaultdict(list) - for version_doc in _version_docs: + for version_doc in version_docs: version_id = version_doc["_id"] product_id = version_doc["parent"] version_docs_by_id[version_id] = version_doc @@ -410,13 +410,16 @@ def _contexts_for_versions(self, project_name, version_ids): project_doc = get_project(project_name) project_doc["code"] = project_doc["data"]["code"] - for product_id, product_doc in product_docs_by_id.items(): + for version_doc in version_docs: + product_id = version_doc["parent"] + product_doc = product_docs_by_id[product_id] folder_id = product_doc["parent"] folder_doc = folder_docs_by_id[folder_id] - product_context_by_id[product_id] = { + version_context_by_id[product_id] = { "project": project_doc, "asset": folder_doc, "subset": product_doc, + "version": version_doc, } repre_docs = get_representations( @@ -437,7 +440,7 @@ def _contexts_for_versions(self, project_name, version_ids): "representation": repre_doc, } - return product_context_by_id, repre_context_by_id + return version_context_by_id, repre_context_by_id def _contexts_for_representations(self, project_name, repre_ids): product_context_by_id = {} @@ -500,11 +503,11 @@ def _contexts_for_representations(self, project_name, repre_ids): def _get_action_items_for_contexts( self, project_name, - product_context_by_id, + version_context_by_id, repre_context_by_id ): action_items = [] - if not product_context_by_id and not repre_context_by_id: + if not version_context_by_id and not repre_context_by_id: return action_items product_loaders, repre_loaders = self._get_loaders(project_name) @@ -544,46 +547,57 @@ def _get_action_items_for_contexts( action_items.append(item) # Subset Loaders. - product_ids = set(product_context_by_id.keys()) + version_ids = set(version_context_by_id.keys()) product_folder_ids = set() - for product_context in product_context_by_id.values(): + product_ids = set() + for product_context in version_context_by_id.values(): + product_ids.add(product_context["subset"]["_id"]) product_folder_ids.add(product_context["asset"]["_id"]) - product_contexts = list(product_context_by_id.values()) + version_contexts = list(version_context_by_id.values()) for loader in product_loaders: item = self._create_loader_action_item( loader, - product_contexts, + version_contexts, project_name=project_name, folder_ids=product_folder_ids, product_ids=product_ids, + version_ids=version_ids, ) action_items.append(item) action_items.sort(key=self._actions_sorter) return action_items - def _trigger_product_loader( + def _trigger_version_loader( self, loader, options, project_name, - product_ids, + version_ids, ): project_doc = get_project(project_name) project_doc["code"] = project_doc["data"]["code"] - product_docs = list(get_subsets(project_name, subset_ids=product_ids)) - folder_ids = {p["parent"]: p for p in product_docs} + + version_docs = list( + get_versions(project_name, version_ids=version_ids)) + product_ids = {v["parent"] for v in version_docs} + product_docs = get_subsets(project_name, subset_ids=product_ids) + product_docs_by_id = {f["_id"]: f for f in product_docs} + folder_ids = {p["parent"] for p in product_docs_by_id.values()} folder_docs = get_assets(project_name, asset_ids=folder_ids) folder_docs_by_id = {f["_id"]: f for f in folder_docs} product_contexts = [] - for product_doc in product_docs: + for version_doc in version_docs: + product_id = version_doc["parent"] + product_doc = product_docs_by_id[product_id] folder_id = product_doc["parent"] folder_doc = folder_docs_by_id[folder_id] product_contexts.append({ "project": project_doc, "asset": folder_doc, "subset": product_doc, + "version": version_doc, }) return self._load_products_by_loader( @@ -642,7 +656,7 @@ def _load_representations_by_loader(self, loader, repre_contexts, options): """ error_info = [] - for repre_context in repre_contexts.values(): + for repre_context in repre_contexts: version_doc = repre_context["version"] if version_doc["type"] == "hero_version": version_name = "Hero" @@ -683,12 +697,17 @@ def _load_representations_by_loader(self, loader, repre_contexts, options): return error_info def _load_products_by_loader(self, loader, version_contexts, options): - """Triggers load with SubsetLoader type of loaders + """Triggers load with SubsetLoader type of loaders. + + Warning: + Plugin is named 'SubsetLoader' but version is passed to context + too. Args: - loader (SubsetLoder): - version_contexts (list): - options (dict): + loader (SubsetLoder): Loader used to load. + version_contexts (list[dict[str, Any]]): For context for each + version. + options (dict[str, Any]): Options for loader that user could fill. """ error_info = [] diff --git a/openpype/tools/ayon_loader/ui/products_widget.py b/openpype/tools/ayon_loader/ui/products_widget.py index 73689220991..5defedee44c 100644 --- a/openpype/tools/ayon_loader/ui/products_widget.py +++ b/openpype/tools/ayon_loader/ui/products_widget.py @@ -281,7 +281,7 @@ def _on_context_menu(self, point): action_item.identifier, options, action_item.project_name, - product_ids=action_item.product_ids, + version_ids=action_item.version_ids, representation_ids=action_item.representation_ids, ) From 42ee8748e2d3ab3c13aec9898f144fc01fb8c5b7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 5 Oct 2023 19:07:50 +0200 Subject: [PATCH 23/69] fix representations widget --- openpype/tools/ayon_loader/ui/repres_widget.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/openpype/tools/ayon_loader/ui/repres_widget.py b/openpype/tools/ayon_loader/ui/repres_widget.py index 8a4f02aadd1..7de582e629c 100644 --- a/openpype/tools/ayon_loader/ui/repres_widget.py +++ b/openpype/tools/ayon_loader/ui/repres_widget.py @@ -5,6 +5,7 @@ from openpype.style import get_default_entity_icon_color from openpype.tools.ayon_utils.widgets import get_qt_icon +from openpype.tools.utils import DeselectableTreeView from .actions_utils import show_actions_menu @@ -210,7 +211,10 @@ class RepresentationsWidget(QtWidgets.QWidget): def __init__(self, controller, parent): super(RepresentationsWidget, self).__init__(parent) - repre_view = QtWidgets.QTreeView(self) + repre_view = DeselectableTreeView(self) + repre_view.setSelectionMode( + QtWidgets.QAbstractItemView.ExtendedSelection + ) repre_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) repre_view.setSortingEnabled(True) repre_view.setAlternatingRowColors(True) @@ -329,6 +333,6 @@ def _on_context_menu(self, point): action_item.identifier, options, action_item.project_name, - product_ids=action_item.product_ids, + version_ids=action_item.version_ids, representation_ids=action_item.representation_ids, ) From 5cb1709f58c9f8cb62ffecf6c8635a8e13db5ecd Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 5 Oct 2023 19:08:14 +0200 Subject: [PATCH 24/69] implemente "in scene" logic properly --- openpype/tools/ayon_loader/__init__.py | 6 ++ openpype/tools/ayon_loader/abstract.py | 48 +++++++++-- openpype/tools/ayon_loader/control.py | 79 +++++++++++++++++-- openpype/tools/ayon_loader/models/products.py | 31 +++++++- .../ayon_loader/ui/products_delegates.py | 33 +++++++- .../tools/ayon_loader/ui/products_model.py | 32 ++++---- .../tools/ayon_loader/ui/products_widget.py | 12 ++- 7 files changed, 212 insertions(+), 29 deletions(-) diff --git a/openpype/tools/ayon_loader/__init__.py b/openpype/tools/ayon_loader/__init__.py index e69de29bb2d..09ecf65f3a0 100644 --- a/openpype/tools/ayon_loader/__init__.py +++ b/openpype/tools/ayon_loader/__init__.py @@ -0,0 +1,6 @@ +from .control import LoaderController + + +__all__ = ( + "LoaderController", +) diff --git a/openpype/tools/ayon_loader/abstract.py b/openpype/tools/ayon_loader/abstract.py index a1a062dc182..67630753af4 100644 --- a/openpype/tools/ayon_loader/abstract.py +++ b/openpype/tools/ayon_loader/abstract.py @@ -28,6 +28,7 @@ def __init__( product_name, product_icon, product_type_icon, + product_in_scene, group_name, folder_id, folder_label, @@ -38,6 +39,7 @@ def __init__( self.product_name = product_name self.product_icon = product_icon self.product_type_icon = product_type_icon + self.product_in_scene = product_in_scene self.group_name = group_name self.folder_id = folder_id self.folder_label = folder_label @@ -50,6 +52,7 @@ def to_data(self): "product_name": self.product_name, "product_icon": self.product_icon, "product_type_icon": self.product_type_icon, + "product_in_scene": self.product_in_scene, "group_name": self.group_name, "folder_id": self.folder_id, "folder_label": self.folder_label, @@ -83,7 +86,6 @@ def __init__( duration, handles, step, - in_scene, comment, source ): @@ -98,7 +100,6 @@ def __init__( self.duration = duration self.handles = handles self.step = step - self.in_scene = in_scene self.comment = comment self.source = source @@ -138,7 +139,6 @@ def to_data(self): "duration": self.duration, "handles": self.handles, "step": self.step, - "in_scene": self.in_scene, "comment": self.comment, "source": self.source, } @@ -179,6 +179,7 @@ def from_data(cls, data): @six.add_metaclass(ABCMeta) class AbstractController: + @abstractmethod def emit_event(self, topic, data=None, source=None): pass @@ -187,10 +188,6 @@ def emit_event(self, topic, data=None, source=None): def register_event_callback(self, topic, callback): pass - @abstractmethod - def get_current_project(self): - pass - @abstractmethod def reset(self): pass @@ -294,3 +291,40 @@ def set_selected_representations(self, repre_ids): @abstractmethod def fill_root_in_source(self, source): pass + + @abstractmethod + def get_current_context(self): + """Current context is a context of the current scene. + + Example output: + { + "project_name": "MyProject", + "folder_id": "0011223344-5566778-99", + "task_name": "Compositing", + } + + Returns: + dict[str, Union[str, None]]: Context data. + """ + + pass + + @abstractmethod + def is_loaded_products_supported(self): + """Is capable to get information about loaded products. + + Returns: + bool: True if it is supported. + """ + + pass + + @abstractmethod + def get_loaded_product_ids(self): + """Return set of loaded product ids. + + Returns: + set[str]: Set of loaded product ids. + """ + + pass diff --git a/openpype/tools/ayon_loader/control.py b/openpype/tools/ayon_loader/control.py index 6c86c8f6aa0..622780cc001 100644 --- a/openpype/tools/ayon_loader/control.py +++ b/openpype/tools/ayon_loader/control.py @@ -1,11 +1,15 @@ import logging +import ayon_api + from openpype.lib.events import QueuedEventSystem -from openpype.pipeline import Anatomy +from openpype.pipeline import Anatomy, get_current_context +from openpype.host import ILoadHost from openpype.tools.ayon_utils.models import ( ProjectsModel, HierarchyModel, NestedCacheItem, + CacheItem, ) from .abstract import AbstractController @@ -13,11 +17,22 @@ class LoaderController(AbstractController): - def __init__(self): + """ + + Args: + host (Optional[AbstractHost]): Host object. Defaults to None. + """ + + def __init__(self, host=None): self._log = None + self._host = host + self._event_system = self._create_event_system() self._project_anatomy_cache = NestedCacheItem(levels=1) + self._loaded_products_cache = CacheItem( + default_factory=set, lifetime=60) + self._selection_model = SelectionModel(self) self._projects_model = ProjectsModel(self) self._hierarchy_model = HierarchyModel(self) @@ -46,13 +61,16 @@ def register_event_callback(self, topic, callback): def reset(self): self._emit_event("controller.reset.started") + + self._project_anatomy_cache.reset() + self._loaded_products_cache.reset() + self._products_model.reset() self._hierarchy_model.reset() + self._loader_actions_model.reset() self._projects_model.refresh() - self._emit_event("controller.reset.finished") - def get_current_project(self): - return None + self._emit_event("controller.reset.finished") # Entity model wrappers def get_project_items(self, sender=None): @@ -145,6 +163,57 @@ def fill_root_in_source(self, source): except Exception: return source + def get_current_context(self): + if self._host is None: + return { + "project_name": None, + "folder_id": None, + "task_name": None, + } + if hasattr(self._host, "get_current_context"): + context = self._host.get_current_context() + else: + context = get_current_context() + folder_id = None + project_name = context.get("project_name") + asset_name = context.get("asset_name") + if project_name and asset_name: + folder = ayon_api.get_folder_by_name( + project_name, asset_name, fields=["id"] + ) + if folder: + folder_id = folder["id"] + return { + "project_name": project_name, + "folder_id": folder_id, + "task_name": context.get("task_name"), + } + + def get_loaded_product_ids(self): + if self._host is None: + return set() + + context = self.get_current_context() + project_name = context["project_name"] + if not project_name: + return set() + + if not self._loaded_products_cache.is_valid: + if isinstance(self._host, ILoadHost): + containers = self._host.get_containers() + else: + containers = self._host.ls() + repre_ids = {c.get("representation") for c in containers} + repre_ids.discard(None) + product_ids = self._products_model.get_product_ids_by_repre_ids( + project_name, repre_ids + ) + self._loaded_products_cache.update_data(product_ids) + return self._loaded_products_cache.get_data() + + def is_loaded_products_supported(self): + return self._host is not None + def _get_project_anatomy(self, project_name): if not project_name: return None diff --git a/openpype/tools/ayon_loader/models/products.py b/openpype/tools/ayon_loader/models/products.py index 4ca6139066b..f9b6c2772fd 100644 --- a/openpype/tools/ayon_loader/models/products.py +++ b/openpype/tools/ayon_loader/models/products.py @@ -61,7 +61,6 @@ def version_item_from_entity(version): duration=duration, handles=handles, step=step, - in_scene=None, comment=comment, source=source, ) @@ -72,6 +71,7 @@ def product_item_from_entity( version_entities, product_type_items_by_name, folder_label, + product_in_scene, ): product_attribs = product_entity["attrib"] group = product_attribs.get("productGroup") @@ -95,6 +95,7 @@ def product_item_from_entity( product_name=product_entity["name"], product_icon=product_icon, product_type_icon=product_type_icon, + product_in_scene=product_in_scene, group_name=group, folder_id=product_entity["folderId"], folder_label=folder_label, @@ -194,6 +195,31 @@ def get_product_item(self, project_name, product_id): ).values(): return product_item + def get_product_ids_by_repre_ids(self, project_name, repre_ids): + """Get product ids based on passed representation ids. + + Args: + project_name (str): Where to look for representations. + repre_ids (Iterable[str]): Representation ids. + + Returns: + set[str]: Product ids for passed representation ids. + """ + + # TODO look out how to use single server call + if not repre_ids: + return set() + repres = ayon_api.get_representations( + project_name, repre_ids, fields=["versionId"] + ) + version_ids = {repre["versionId"] for repre in repres} + if not version_ids: + return set() + versions = ayon_api.get_versions( + project_name, version_ids=version_ids, fields=["productId"] + ) + return {v["productId"] for v in versions} + def _get_product_items_by_id(self, project_name, product_ids): product_item_by_id = self._product_item_by_id[project_name] missing_product_ids = set() @@ -244,6 +270,8 @@ def _create_product_items( if product_type_items is None: product_type_items = self.get_product_type_items(project_name) + loaded_product_ids = self._controller.get_loaded_product_ids() + versions_by_product_id = collections.defaultdict(list) for version in versions: versions_by_product_id[version["productId"]].append(version) @@ -266,6 +294,7 @@ def _create_product_items( versions, product_type_items_by_name, folder_item.label, + product_id in loaded_product_ids, ) output[product_id] = product_item return output diff --git a/openpype/tools/ayon_loader/ui/products_delegates.py b/openpype/tools/ayon_loader/ui/products_delegates.py index 0dcdc96aab6..6729468bfab 100644 --- a/openpype/tools/ayon_loader/ui/products_delegates.py +++ b/openpype/tools/ayon_loader/ui/products_delegates.py @@ -1,13 +1,13 @@ import numbers from qtpy import QtWidgets, QtCore, QtGui -from openpype.pipeline import HeroVersionType from openpype.tools.utils.lib import format_version from .products_model import ( PRODUCT_ID_ROLE, VERSION_NAME_EDIT_ROLE, VERSION_ID_ROLE, + PRODUCT_IN_SCENE_ROLE, ) @@ -158,3 +158,34 @@ def setModelData(self, editor, model, index): version_id = editor.itemData(editor.currentIndex()) model.setData(index, version_id, VERSION_NAME_EDIT_ROLE) + + +class LoadedInSceneDelegate(QtWidgets.QStyledItemDelegate): + """Delegate for Loaded in Scene state columns. + + Shows "Yes" or "No" for 1 or 0 values, or "N/A" for other values. + Colorizes green or dark grey based on values. + """ + + def __init__(self, *args, **kwargs): + super(LoadedInSceneDelegate, self).__init__(*args, **kwargs) + self._colors = { + 1: QtGui.QColor(80, 170, 80), + 0: QtGui.QColor(90, 90, 90), + } + self._default_color = QtGui.QColor(90, 90, 90) + + def displayText(self, value, locale): + if value == 0: + return "No" + elif value == 1: + return "Yes" + return "N/A" + + def initStyleOption(self, option, index): + super(LoadedInSceneDelegate, self).initStyleOption(option, index) + + # Colorize based on value + value = index.data(PRODUCT_IN_SCENE_ROLE) + color = self._colors.get(value, self._default_color) + option.palette.setBrush(QtGui.QPalette.Text, color) diff --git a/openpype/tools/ayon_loader/ui/products_model.py b/openpype/tools/ayon_loader/ui/products_model.py index f4c3ea38f64..482fc48f361 100644 --- a/openpype/tools/ayon_loader/ui/products_model.py +++ b/openpype/tools/ayon_loader/ui/products_model.py @@ -15,17 +15,17 @@ PRODUCT_NAME_ROLE = QtCore.Qt.UserRole + 6 PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 7 PRODUCT_TYPE_ICON_ROLE = QtCore.Qt.UserRole + 8 -VERSION_ID_ROLE = QtCore.Qt.UserRole + 9 -VERSION_HERO_ROLE = QtCore.Qt.UserRole + 10 -VERSION_NAME_ROLE = QtCore.Qt.UserRole + 11 -VERSION_NAME_EDIT_ROLE = QtCore.Qt.UserRole + 12 -VERSION_PUBLISH_TIME_ROLE = QtCore.Qt.UserRole + 13 -VERSION_AUTHOR_ROLE = QtCore.Qt.UserRole + 14 -VERSION_FRAME_RANGE_ROLE = QtCore.Qt.UserRole + 15 -VERSION_DURATION_ROLE = QtCore.Qt.UserRole + 16 -VERSION_HANDLES_ROLE = QtCore.Qt.UserRole + 17 -VERSION_STEP_ROLE = QtCore.Qt.UserRole + 18 -VERSION_IN_SCENE_ROLE = QtCore.Qt.UserRole + 19 +PRODUCT_IN_SCENE_ROLE = QtCore.Qt.UserRole + 9 +VERSION_ID_ROLE = QtCore.Qt.UserRole + 10 +VERSION_HERO_ROLE = QtCore.Qt.UserRole + 11 +VERSION_NAME_ROLE = QtCore.Qt.UserRole + 12 +VERSION_NAME_EDIT_ROLE = QtCore.Qt.UserRole + 13 +VERSION_PUBLISH_TIME_ROLE = QtCore.Qt.UserRole + 14 +VERSION_AUTHOR_ROLE = QtCore.Qt.UserRole + 15 +VERSION_FRAME_RANGE_ROLE = QtCore.Qt.UserRole + 16 +VERSION_DURATION_ROLE = QtCore.Qt.UserRole + 17 +VERSION_HANDLES_ROLE = QtCore.Qt.UserRole + 18 +VERSION_STEP_ROLE = QtCore.Qt.UserRole + 19 VERSION_AVAILABLE_ROLE = QtCore.Qt.UserRole + 20 VERSION_THUMBNAIL_ID_ROLE = QtCore.Qt.UserRole + 21 @@ -66,6 +66,7 @@ class ProductsModel(QtGui.QStandardItemModel): version_col = column_labels.index("Version") published_time_col = column_labels.index("Time") folders_label_col = column_labels.index("Folder") + in_scene_col = column_labels.index("In scene") def __init__(self, controller): super(ProductsModel, self).__init__() @@ -180,7 +181,7 @@ def data(self, index, role=None): elif col == 9: role = VERSION_STEP_ROLE elif col == 10: - role = VERSION_IN_SCENE_ROLE + role = PRODUCT_IN_SCENE_ROLE elif col == 11: role = VERSION_AVAILABLE_ROLE else: @@ -288,7 +289,6 @@ def _set_version_data_to_product_item(self, model_item, version_item): model_item.setData(version_item.duration, VERSION_DURATION_ROLE) model_item.setData(version_item.handles, VERSION_HANDLES_ROLE) model_item.setData(version_item.step, VERSION_STEP_ROLE) - model_item.setData(version_item.in_scene, VERSION_IN_SCENE_ROLE) model_item.setData( version_item.thumbnail_id, VERSION_THUMBNAIL_ID_ROLE) @@ -310,10 +310,14 @@ def _get_product_model_item(self, product_item): model_item.setData(product_item.product_type, PRODUCT_TYPE_ROLE) model_item.setData(product_type_icon, PRODUCT_TYPE_ICON_ROLE) model_item.setData(product_item.folder_id, FOLDER_ID_ROLE) - model_item.setData(product_item.folder_label, FOLDER_LABEL_ROLE) self._product_items_by_id[product_id] = product_item self._items_by_id[product_id] = model_item + + model_item.setData(product_item.folder_label, FOLDER_LABEL_ROLE) + in_scene = 1 if product_item.product_in_scene else 0 + model_item.setData(in_scene, PRODUCT_IN_SCENE_ROLE) + self._set_version_data_to_product_item(model_item, last_version) return model_item diff --git a/openpype/tools/ayon_loader/ui/products_widget.py b/openpype/tools/ayon_loader/ui/products_widget.py index 5defedee44c..45ea5167607 100644 --- a/openpype/tools/ayon_loader/ui/products_widget.py +++ b/openpype/tools/ayon_loader/ui/products_widget.py @@ -19,7 +19,7 @@ VERSION_ID_ROLE, VERSION_THUMBNAIL_ID_ROLE, ) -from .products_delegates import VersionDelegate +from .products_delegates import VersionDelegate, LoadedInSceneDelegate from .actions_utils import show_actions_menu @@ -130,6 +130,10 @@ def __init__(self, controller, parent): products_view.setItemDelegateForColumn( products_model.published_time_col, time_delegate) + in_scene_delegate = LoadedInSceneDelegate() + products_view.setItemDelegateForColumn( + products_model.in_scene_col, in_scene_delegate) + main_layout = QtWidgets.QHBoxLayout(self) main_layout.setContentsMargins(0, 0, 0, 0) main_layout.addWidget(products_view, 1) @@ -174,7 +178,13 @@ def __init__(self, controller, parent): self._selected_versions_info = [] # Set initial state of widget + # - Hide folders column self._update_folders_label_visible() + # - Hide in scene column if is not supported (this won't change) + products_view.setColumnHidden( + products_model.in_scene_col, + not controller.is_loaded_products_supported() + ) def set_name_filer(self, name): """Set filter of product name. From a751c7b6dde63093272c18962b3c955cd990dd29 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 5 Oct 2023 19:08:24 +0200 Subject: [PATCH 25/69] use ayon loader in host tools --- openpype/tools/utils/host_tools.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/openpype/tools/utils/host_tools.py b/openpype/tools/utils/host_tools.py index 2ebc973a47c..515865ec0c3 100644 --- a/openpype/tools/utils/host_tools.py +++ b/openpype/tools/utils/host_tools.py @@ -86,12 +86,22 @@ def show_workfiles( def get_loader_tool(self, parent): """Create, cache and return loader tool window.""" if self._loader_tool is None: - from openpype.tools.loader import LoaderWindow - host = registered_host() ILoadHost.validate_load_methods(host) + if AYON_SERVER_ENABLED: + from openpype.tools.ayon_loader.ui import LoaderWindow + from openpype.tools.ayon_loader import LoaderController + + controller = LoaderController(host=host) + loader_window = LoaderWindow( + controller=controller, + parent=parent or self._parent + ) + + else: + from openpype.tools.loader import LoaderWindow - loader_window = LoaderWindow(parent=parent or self._parent) + loader_window = LoaderWindow(parent=parent or self._parent) self._loader_tool = loader_window return self._loader_tool From 59cbd1210b5a018dc6522d553f187449208bba53 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 6 Oct 2023 10:56:06 +0200 Subject: [PATCH 26/69] fix used function to get tasks --- openpype/tools/ayon_utils/models/hierarchy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/ayon_utils/models/hierarchy.py b/openpype/tools/ayon_utils/models/hierarchy.py index 585f09aec43..47fac08e99d 100644 --- a/openpype/tools/ayon_utils/models/hierarchy.py +++ b/openpype/tools/ayon_utils/models/hierarchy.py @@ -382,7 +382,7 @@ def _query_task_entities(self, project_name, task_ids): return project_cache = self._tasks_by_id[project_name] - tasks = ayon_api.get_folders(project_name, task_ids=task_ids) + tasks = ayon_api.get_tasks(project_name, task_ids=task_ids) for task in tasks: task_id = task["id"] project_cache[task_id].update_data(task) From 1edd985f8becae3b7347ae9924a884f45a753f18 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 6 Oct 2023 11:04:06 +0200 Subject: [PATCH 27/69] show actions per representation name --- openpype/tools/ayon_loader/models/actions.py | 66 +++++++++++--------- 1 file changed, 36 insertions(+), 30 deletions(-) diff --git a/openpype/tools/ayon_loader/models/actions.py b/openpype/tools/ayon_loader/models/actions.py index 0c9e2f1e3bd..e3276373e6c 100644 --- a/openpype/tools/ayon_loader/models/actions.py +++ b/openpype/tools/ayon_loader/models/actions.py @@ -309,11 +309,15 @@ def _create_loader_action_item( folder_ids=None, product_ids=None, version_ids=None, - representation_ids=None + representation_ids=None, + repre_name=None, ): + label = self._get_loader_label(loader) + if repre_name: + label = "{} ({})".format(label, repre_name) return ActionItem( get_loader_identifier(loader), - label=self._get_loader_label(loader), + label=label, icon=self._get_loader_icon(loader), tooltip=self._get_loader_tooltip(loader), options=loader.get_options(contexts), @@ -481,8 +485,6 @@ def _contexts_for_representations(self, project_name, repre_ids): "subset": product_doc, } - repre_docs = get_representations( - project_name, version_ids=version_ids) for repre_doc in repre_docs: version_id = repre_doc["parent"] version_doc = version_docs_by_id[version_id] @@ -512,39 +514,43 @@ def _get_action_items_for_contexts( product_loaders, repre_loaders = self._get_loaders(project_name) - repre_contexts = list(repre_context_by_id.values()) - repre_ids = set(repre_context_by_id.keys()) - repre_version_ids = set() - repre_product_ids = set() - repre_folder_ids = set() + repre_contexts_by_name = collections.defaultdict(list) for repre_context in repre_context_by_id.values(): - repre_product_ids.add(repre_context["subset"]["_id"]) - repre_version_ids.add(repre_context["version"]["_id"]) - repre_folder_ids.add(repre_context["asset"]["_id"]) + repre_name = repre_context["representation"]["name"] + repre_contexts_by_name[repre_name].append(repre_context) for loader in repre_loaders: - if not repre_contexts: - break - # # do not allow download whole repre, select specific repre # if tools_lib.is_sync_loader(loader): # continue - filtered_repre_contexts = filter_repre_contexts_by_loader( - repre_contexts, loader) - if len(filtered_repre_contexts) != len(repre_contexts): - continue - - item = self._create_loader_action_item( - loader, - repre_contexts, - project_name=project_name, - folder_ids=repre_folder_ids, - product_ids=repre_product_ids, - version_ids=repre_version_ids, - representation_ids=repre_ids - ) - action_items.append(item) + for repre_name, repre_contexts in repre_contexts_by_name.items(): + repre_ids = set() + repre_version_ids = set() + repre_product_ids = set() + repre_folder_ids = set() + for repre_context in repre_context_by_id.values(): + repre_ids.add(repre_context["representation"]["_id"]) + repre_product_ids.add(repre_context["subset"]["_id"]) + repre_version_ids.add(repre_context["version"]["_id"]) + repre_folder_ids.add(repre_context["asset"]["_id"]) + + filtered_repre_contexts = filter_repre_contexts_by_loader( + repre_contexts, loader) + if len(filtered_repre_contexts) != len(repre_contexts): + continue + + item = self._create_loader_action_item( + loader, + repre_contexts, + project_name=project_name, + folder_ids=repre_folder_ids, + product_ids=repre_product_ids, + version_ids=repre_version_ids, + representation_ids=repre_ids, + repre_name=repre_name, + ) + action_items.append(item) # Subset Loaders. version_ids = set(version_context_by_id.keys()) From b9742520f4be749835996efdd324215e0357bc5d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 6 Oct 2023 11:39:40 +0200 Subject: [PATCH 28/69] center window --- openpype/tools/ayon_loader/ui/window.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/openpype/tools/ayon_loader/ui/window.py b/openpype/tools/ayon_loader/ui/window.py index b3b436b4fb0..2435ecd6f1a 100644 --- a/openpype/tools/ayon_loader/ui/window.py +++ b/openpype/tools/ayon_loader/ui/window.py @@ -3,6 +3,7 @@ from openpype.resources import get_openpype_icon_filepath from openpype.style import load_stylesheet from openpype.tools.utils import PlaceholderLineEdit, ErrorMessageBox +from openpype.tools.utils.lib import center_window from openpype.tools.ayon_utils.widgets import ProjectsCombobox from openpype.tools.ayon_loader.control import LoaderController @@ -218,13 +219,10 @@ def showEvent(self, event): def _on_first_show(self): self._first_show = False - # if self._controller.is_site_sync_enabled(): - # self.resize(1800, 900) - # else: - # self.resize(1300, 700) self.resize(1800, 900) - self._main_splitter.setSizes([250, 1000, 550]) + self._main_splitter.setSizes([350, 1000, 350]) self.setStyleSheet(load_stylesheet()) + center_window(self) self._controller.reset() def _on_show_timer(self): From 062966bedacfdf1b241604ba1096bee24dd83f57 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 6 Oct 2023 11:39:51 +0200 Subject: [PATCH 29/69] add window flag to loader window --- openpype/tools/ayon_loader/ui/window.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/tools/ayon_loader/ui/window.py b/openpype/tools/ayon_loader/ui/window.py index 2435ecd6f1a..84bf9660530 100644 --- a/openpype/tools/ayon_loader/ui/window.py +++ b/openpype/tools/ayon_loader/ui/window.py @@ -84,6 +84,7 @@ def __init__(self, controller=None, parent=None): self.setWindowTitle("Loader") self.setFocusPolicy(QtCore.Qt.StrongFocus) self.setAttribute(QtCore.Qt.WA_DeleteOnClose, False) + self.setWindowFlags(self.windowFlags() | QtCore.Qt.Window) if controller is None: controller = LoaderController() From 3f33141985818a7ff8c0feeb5484cd9e653531f9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 6 Oct 2023 12:07:01 +0200 Subject: [PATCH 30/69] added 'ThumbnailPainterWidget' to tool utils --- openpype/tools/utils/__init__.py | 3 + openpype/tools/utils/images/__init__.py | 56 +++ openpype/tools/utils/images/thumbnail.png | Bin 0 -> 5118 bytes .../tools/utils/thumbnail_paint_widget.py | 341 ++++++++++++++++++ 4 files changed, 400 insertions(+) create mode 100644 openpype/tools/utils/images/__init__.py create mode 100644 openpype/tools/utils/images/thumbnail.png create mode 100644 openpype/tools/utils/thumbnail_paint_widget.py diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py index 018088e916d..ed41d93f0d1 100644 --- a/openpype/tools/utils/__init__.py +++ b/openpype/tools/utils/__init__.py @@ -43,6 +43,7 @@ MessageOverlayObject, ) from .multiselection_combobox import MultiSelectionComboBox +from .thumbnail_paint_widget import ThumbnailPainterWidget __all__ = ( @@ -90,4 +91,6 @@ "MessageOverlayObject", "MultiSelectionComboBox", + + "ThumbnailPainterWidget", ) diff --git a/openpype/tools/utils/images/__init__.py b/openpype/tools/utils/images/__init__.py new file mode 100644 index 00000000000..3f437fcc8c6 --- /dev/null +++ b/openpype/tools/utils/images/__init__.py @@ -0,0 +1,56 @@ +import os +from qtpy import QtGui + +IMAGES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__))) + + +def get_image_path(filename): + """Get image path from './images'. + + Returns: + Union[str, None]: Path to image file or None if not found. + """ + + path = os.path.join(IMAGES_DIR, filename) + if os.path.exists(path): + return path + return None + + +def get_image(filename): + """Load image from './images' as QImage. + + Returns: + Union[QtGui.QImage, None]: QImage or None if not found. + """ + + path = get_image_path(filename) + if path: + return QtGui.QImage(path) + return None + + +def get_pixmap(filename): + """Load image from './images' as QPixmap. + + Returns: + Union[QtGui.QPixmap, None]: QPixmap or None if not found. + """ + + path = get_image_path(filename) + if path: + return QtGui.QPixmap(path) + return None + + +def get_icon(filename): + """Load image from './images' as QIcon. + + Returns: + Union[QtGui.QIcon, None]: QIcon or None if not found. + """ + + pix = get_pixmap(filename) + if pix: + return QtGui.QIcon(pix) + return None diff --git a/openpype/tools/utils/images/thumbnail.png b/openpype/tools/utils/images/thumbnail.png new file mode 100644 index 0000000000000000000000000000000000000000..adea862e5b7169f8279bd398226a2fe6283e4925 GIT binary patch literal 5118 zcmeHLc{o+y*Wc&di)+d?ln^c%BBG)wa!nyKSB5fFU$dgF>1#ZSqKGdM5{f99GH1G1 zh7cl|uP9@#44Juj@AvopJ@4P|@BQqMCt>9Di#vj70t z^>nq&0D$RJ7+_Fz)|eyKN*AU~3<%nEasU6v|7YO;bO!o@*d^&7`ntE7kvV{16qa>F5nX^jD z=gzCBUQolUYiMd|U)0gnyQFVmXk=_+YGzKbxNK=$ zdCSx5_8p?PkFTHqp8KPD!pre|h<&Qa%oEi5iA|6W;LTi@8++TNkDL2eFprmbr}?KH8gV`mHH zJEJ_t8$a3>GEhSI|DOy*&g@RE>ie9kva5L3slZ1AGU-X&Yfjs)jh08IJAAxnhph5` zFIUPawdzr+ue{xNR38aUyJP-M)ZSMoK+>=QaApnms&kM zA=HR+!BzK(3YO{NAbt99XZheR?I?{Qu3`N>M<#EsONme+D!El8)#EM|2+d#(&y4v1 zPp?Rcz)l}9qO6s0(?<}bMMRepyVR`Yrg|+)x2pp9ONOF zoY42rI_sw_xsah-g*=6XZw5f(8DYY=#;;RM$Jk#Hn*GCrvKz9oc^H=OYvmVF@5I3J zy;Je^4%9(Mw8P8N^-tSLvBA#6Az#$dnU7lb6E{@lB4RLPCDsV76=rhPSrAU}U1Xi) zM1*XF;|1{6$6@*QQzAoLuzVYCXodH7pnVhZAmp;l;_z%0G|jl89ijun;R$9kPmJ$d z%=lXcSiZ?tBkH_|${HSCvTr}22=U}%5zhOp+V?pTPCLRua$eJCRRqvNxr;AZ0E+?) z8EmjPN^l}j{~OkEHFPE^seBH8-Db2pzspSaJQ|^K6oU_BwP2Lb0A(8~?cjpm>O4II zpG$s7qh8-M0;x5GV~Ls|^^>Ilc@S+J$^dQ7$GV}C>8hRFVQ`rXneKnf?jQ)cESQ%= zVgZ8w%5)3;a%f%@bP8S;AdA4=R}+Up9+%3lBN{soq?vIPHMkvvqma-G21~2E7~G&7 zsuBx4(VR$Ey14+E1@5LCDTjE({I~U)$<8;vGI|Nn;knA8RT0p+MU%Yt9z9-ZD2*C) z;6xULEcnPOaJQX-BhaL8g|CmrlR=!u_2LM;G|KM-)jDz_dkX|!vK&1XDIW=)`y<{P zo|;%B;5dPym?8?e*~|~(qd_!w=;f?!rtXiK1%1GOxIhBAA`8oFwx2n;BKMFBk)mBW zWC#Liyv3$_G_|r7VWjT?D2qE9Y++?+{|K=C4xa~mPMnAyZTrjXN6dI`%K_b8NWN7R zGwYP9exX{hT%#-<-aD0<_gbLCi$t^_fslRa5vK0PEMIC2N6<;x!d2kL{@ckBW z{QSUE{lMNK+SqJ5WhML^a!7kUq;z5aW3#sB{)fFyHIl3wCu07_cmJ=Op448B*RdXZ zYhw<)Mdvh+kb_3{$tA2mA$Jn*E#WA66ZFVcZ|46g{S^579pmp@13qD zecE`gA%IWMs$-MO-QLW~TU@Q2^EWkF>tC&3D9W?6LMeUxp0waBnw0TRF|s1qh-hj5 z?EhdvTp^Zbt9V(VTDL#^q|QGmz_YjlsiO0tS-;d_j1gFg-_m@NwN2yCP zMPs7@qs05<0yhkV7C~+~wtc%*R3+vGrSaB-Q)g;6n{16?c~xXq|JL55ndJt zV6DY}P7K6yBY#01Pu8{qPu>G;Yz}3$i!%?6*)D<6Ms9r9Z4u*N1!xB=AgCW<&^N$^ zm_iCWR;@nsmtc7*#8IQ-C@-T3k`W<>JO~eZWy?%{0xo`)#^C2&jDZu{tD#aO%D5zk zV?0eB-My~=qIKIF?p8xusNtvH_G0imAPEzZBoK0Ac7B}6i)7-)3C#+Umu$+R$P{!Y z5!4+IcQeP}$H4GB;X)`UsdpSyGn_{*F0ybCK4S2_V4BsKFsZw}FYQSw`^o*l8wd0x zFtJab&+J|AU9{e4Tzq?FKj;voE1%i^7Brl@08j>yWwt?E&Z6Te0SB=cm>!?q}s;+_?9tSd4cndDfgH@rf%_K zsDs;1-#77UMqP>UUqHq^e(4*lUhsFyVEeZ4ax)LCYh+i>0Y#F|le%@)?`2%JdKsX4 zZN`Cd+?zdHhL~pO(woREv}}xyL(jP598>0&21skn!prKg*IrILX*ZP=8B4tLLiHvk z@8MX10%D}j=8(Q*+!?c8blQn}*{4&Mer!fR7=OGON?fdy{H1!muN*zzM0T@BVRd!} zca+P?>qqlwJT^=4>}81{-|ntT14X}*!tGY=c3A1S3shx|lKQmJRLOY$=Izx;rfP1a z^E{AaW)JF@Zg8BQCvHZH3u4GgDH^Pcj=Gy`_QRbmSO$5CLLG4Z29>d}1efxbCawj zrBPhSs$$%Be2{ywCJv5Nk^3;w+KgT@KdqH~WL6#I3YZGy0j)MZw3oDnJ8ms;$+tB4 zS0@L4lwUVE_=!4DbD`B^Uik5IlQ)tgP2zpri=Y35C~_KeDHzB~C_n7$01Pkv!Jb{8 z9pq~9x(agR)OH+t!E;=cee@sysv5)FL|84A-|8TMZKL0+TjPWa+ zMHZ%_?J>8Db#zg!C$4X5V6J3n-PCp0MCk@6^n`vMEl@W{S&_#>3CVo*K;q=Od2O$l z2gH`=G?#nlomoX<6tr1Rccg#x8Vk<-IQRaW2oVtEL1k+SVhP2j2sUZD5R&s|<~2YjexG)q!_eh-U3Jb-U6 zse8T!g%jzA>e-dU!sOEiJu&6Xp6oePK{CNDBrKtWZ+6!Ig|_Yz`u*gu-;X8%8p}?!mcw#X+k^ z`r9`xJ3pP!bd|;B+!QRHwI45Cs*)E9^1}$OJ^N#~dt?!vl=w>w)l77o=7oXtpdX)i zNZ4=N|4SLmxChXE>SF#d z8|2s881<@@@5;JPd! 100: + width = int(width * 0.6) + height = int(height * 0.6) + + scaled_pix = self._get_default_pix().scaled( + width, + height, + QtCore.Qt.KeepAspectRatio, + QtCore.Qt.SmoothTransformation + ) + pos_x = int( + (pix_width - scaled_pix.width()) / 2 + ) + pos_y = int( + (pix_height - scaled_pix.height()) / 2 + ) + new_pix = QtGui.QPixmap(pix_width, pix_height) + new_pix.fill(QtCore.Qt.transparent) + pix_painter = QtGui.QPainter() + pix_painter.begin(new_pix) + render_hints = ( + QtGui.QPainter.Antialiasing + | QtGui.QPainter.SmoothPixmapTransform + ) + if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): + render_hints |= QtGui.QPainter.HighQualityAntialiasing + + pix_painter.setRenderHints(render_hints) + pix_painter.drawPixmap(pos_x, pos_y, scaled_pix) + pix_painter.end() + return new_pix + + def _draw_thumbnails(self, thumbnails, pix_width, pix_height): + full_border_width = 2 * self.border_width + + checker_pix = self._paint_checker(pix_width, pix_height) + + backgrounded_images = [] + for src_pix in thumbnails: + scaled_pix = src_pix.scaled( + pix_width - full_border_width, + pix_height - full_border_width, + QtCore.Qt.KeepAspectRatio, + QtCore.Qt.SmoothTransformation + ) + pos_x = int( + (pix_width - scaled_pix.width()) / 2 + ) + pos_y = int( + (pix_height - scaled_pix.height()) / 2 + ) + + new_pix = QtGui.QPixmap(pix_width, pix_height) + new_pix.fill(QtCore.Qt.transparent) + pix_painter = QtGui.QPainter() + pix_painter.begin(new_pix) + render_hints = ( + QtGui.QPainter.Antialiasing + | QtGui.QPainter.SmoothPixmapTransform + ) + if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): + render_hints |= QtGui.QPainter.HighQualityAntialiasing + pix_painter.setRenderHints(render_hints) + + tiled_rect = QtCore.QRectF( + pos_x, pos_y, scaled_pix.width(), scaled_pix.height() + ) + pix_painter.drawTiledPixmap( + tiled_rect, + checker_pix, + QtCore.QPointF(0.0, 0.0) + ) + pix_painter.drawPixmap(pos_x, pos_y, scaled_pix) + pix_painter.end() + backgrounded_images.append(new_pix) + return backgrounded_images + + def _paint_dash_line(self, painter, rect): + pen = QtGui.QPen() + pen.setWidth(1) + pen.setBrush(QtCore.Qt.darkGray) + pen.setStyle(QtCore.Qt.DashLine) + + new_rect = rect.adjusted(1, 1, -1, -1) + painter.setPen(pen) + painter.setBrush(QtCore.Qt.transparent) + # painter.drawRect(rect) + painter.drawRect(new_rect) + + def _cache_pix(self): + rect = self.rect() + rect_width = rect.width() + rect_height = rect.height() + + pix_x_offset = 0 + pix_y_offset = 0 + expected_height = int( + (rect_width / self.width_ratio) * self.height_ratio + ) + if expected_height > rect_height: + expected_height = rect_height + expected_width = int( + (rect_height / self.height_ratio) * self.width_ratio + ) + pix_x_offset = (rect_width - expected_width) / 2 + else: + expected_width = rect_width + pix_y_offset = (rect_height - expected_height) / 2 + + if self._current_pixes is None: + used_default_pix = True + pixes_to_draw = None + pixes_len = 1 + else: + used_default_pix = False + pixes_to_draw = self._current_pixes + if len(pixes_to_draw) > self.max_thumbnails: + pixes_to_draw = pixes_to_draw[:-self.max_thumbnails] + pixes_len = len(pixes_to_draw) + + width_offset, height_offset = self._get_pix_offset_size( + expected_width, expected_height, pixes_len + ) + pix_width = expected_width - width_offset + pix_height = expected_height - height_offset + + if used_default_pix: + thumbnail_images = [self._paint_default_pix(pix_width, pix_height)] + else: + thumbnail_images = self._draw_thumbnails( + pixes_to_draw, pix_width, pix_height + ) + + if pixes_len == 1: + width_offset_part = 0 + height_offset_part = 0 + else: + width_offset_part = int(float(width_offset) / (pixes_len - 1)) + height_offset_part = int(float(height_offset) / (pixes_len - 1)) + full_width_offset = width_offset + pix_x_offset + + final_pix = QtGui.QPixmap(rect_width, rect_height) + final_pix.fill(QtCore.Qt.transparent) + + bg_pen = QtGui.QPen() + bg_pen.setWidth(self.border_width) + bg_pen.setColor(self._border_color) + + final_painter = QtGui.QPainter() + final_painter.begin(final_pix) + render_hints = ( + QtGui.QPainter.Antialiasing + | QtGui.QPainter.SmoothPixmapTransform + ) + if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): + render_hints |= QtGui.QPainter.HighQualityAntialiasing + + final_painter.setRenderHints(render_hints) + + final_painter.setBrush(QtGui.QBrush(self._thumbnail_bg_color)) + final_painter.setPen(bg_pen) + final_painter.drawRect(rect) + + for idx, pix in enumerate(thumbnail_images): + x_offset = full_width_offset - (width_offset_part * idx) + y_offset = (height_offset_part * idx) + pix_y_offset + final_painter.drawPixmap(x_offset, y_offset, pix) + + # Draw drop enabled dashes + if used_default_pix: + self._paint_dash_line(final_painter, rect) + + final_painter.end() + + self._cached_pix = final_pix + + def _get_pix_offset_size(self, width, height, image_count): + if image_count == 1: + return 0, 0 + + part_width = width / self.offset_sep + part_height = height / self.offset_sep + return part_width, part_height From d4fa75cc9c73a2dfc3ed96fda5e83f2ba5c3034d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 6 Oct 2023 14:22:09 +0200 Subject: [PATCH 31/69] implemented thumbnails model --- openpype/tools/ayon_utils/models/__init__.py | 3 + .../tools/ayon_utils/models/thumbnails.py | 116 ++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 openpype/tools/ayon_utils/models/thumbnails.py diff --git a/openpype/tools/ayon_utils/models/__init__.py b/openpype/tools/ayon_utils/models/__init__.py index 1434282c5bf..69722b5e219 100644 --- a/openpype/tools/ayon_utils/models/__init__.py +++ b/openpype/tools/ayon_utils/models/__init__.py @@ -12,6 +12,7 @@ HierarchyModel, HIERARCHY_MODEL_SENDER, ) +from .thumbnails import ThumbnailsModel __all__ = ( @@ -26,4 +27,6 @@ "TaskItem", "HierarchyModel", "HIERARCHY_MODEL_SENDER", + + "ThumbnailsModel", ) diff --git a/openpype/tools/ayon_utils/models/thumbnails.py b/openpype/tools/ayon_utils/models/thumbnails.py new file mode 100644 index 00000000000..ea7a4174efb --- /dev/null +++ b/openpype/tools/ayon_utils/models/thumbnails.py @@ -0,0 +1,116 @@ +import collections + +import ayon_api + +from openpype.client.server.thumbnails import AYONThumbnailCache + +from .cache import NestedCacheItem + + +class ThumbnailsModel: + entity_cache_lifetime = 240 + def __init__(self): + self._thumbnail_cache = AYONThumbnailCache() + self._paths_cache = collections.defaultdict(dict) + self._folders_cache = NestedCacheItem( + levels=2, lifetime=self.entity_cache_lifetime) + self._versions_cache = NestedCacheItem( + levels=2, lifetime=self.entity_cache_lifetime) + + def reset(self): + self._paths_cache = collections.defaultdict(dict) + self._folders_cache.reset() + self._versions_cache.reset() + + def get_thumbnail_path(self, project_name, thumbnail_id): + return self._get_thumbnail_path(project_name, thumbnail_id) + + def get_folder_thumbnail_ids(self, project_name, folder_ids): + project_cache = self._folders_cache[project_name] + output = {} + missing_cache = set() + for folder_id in folder_ids: + cache = project_cache[folder_id] + if cache.is_valid: + output[folder_id] = cache.get_data() + else: + missing_cache.add(folder_id) + self._query_folder_thumbnail_ids(project_name, missing_cache) + for folder_id in missing_cache: + cache = project_cache[folder_id] + output[folder_id] = cache.get_data() + return output + + def get_version_thumbnail_ids(self, project_name, version_ids): + project_cache = self._versions_cache[project_name] + output = {} + missing_cache = set() + for version_id in version_ids: + cache = project_cache[version_id] + if cache.is_valid: + output[version_id] = cache.get_data() + else: + missing_cache.add(version_id) + self._query_version_thumbnail_ids(project_name, missing_cache) + for version_id in missing_cache: + cache = project_cache[version_id] + output[version_id] = cache.get_data() + return output + + def _get_thumbnail_path(self, project_name, thumbnail_id): + if not thumbnail_id: + return None + + project_cache = self._paths_cache[project_name] + if thumbnail_id in project_cache: + return project_cache[thumbnail_id] + + filepath = self._thumbnail_cache.get_thumbnail_filepath( + project_name, thumbnail_id + ) + if filepath is not None: + project_cache[thumbnail_id] = filepath + return filepath + + result = ayon_api.get_thumbnail_by_id(project_name, thumbnail_id) + if result is None: + # Caused by bug in 'ayon_api'. Public function + # 'get_thumbnail_by_id' does not return output of + # 'ServerAPI' method. + pass + + elif result.is_valid: + filepath = self._thumbnail_cache.store_thumbnail( + project_name, + thumbnail_id, + result.content, + result.content_type + ) + project_cache[thumbnail_id] = filepath + return filepath + + def _query_folder_thumbnail_ids(self, project_name, folder_ids): + if not project_name or not folder_ids: + return + + folders = ayon_api.get_folders( + project_name, + folder_ids=folder_ids, + fields=["id", "thumbnailId"] + ) + project_cache = self._folders_cache[project_name] + for folder in folders: + project_cache[folder["id"]] = folder["thumbnailId"] + + def _query_version_thumbnail_ids(self, project_name, version_ids): + if not project_name or not version_ids: + return + + versions = ayon_api.get_versions( + project_name, + version_ids=version_ids, + fields=["id", "thumbnailId"] + ) + project_cache = self._versions_cache[project_name] + for version in versions: + project_cache[version["id"]] = version["thumbnailId"] From 533c0a32f1b53a85e766d85c5ae868a89bbabcae Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 6 Oct 2023 14:23:04 +0200 Subject: [PATCH 32/69] implement thumbnail widget --- openpype/tools/ayon_loader/abstract.py | 12 ++++ openpype/tools/ayon_loader/control.py | 16 ++++++ openpype/tools/ayon_loader/ui/window.py | 73 ++++++++++++++++++++++++- 3 files changed, 100 insertions(+), 1 deletion(-) diff --git a/openpype/tools/ayon_loader/abstract.py b/openpype/tools/ayon_loader/abstract.py index 67630753af4..65598d8e8d7 100644 --- a/openpype/tools/ayon_loader/abstract.py +++ b/openpype/tools/ayon_loader/abstract.py @@ -233,6 +233,18 @@ def get_representation_items( def get_folder_entity(self, project_name, folder_id): pass + @abstractmethod + def get_version_thumbnail_ids(self, project_name, version_ids): + pass + + @abstractmethod + def get_folder_thumbnail_ids(self, project_name, folder_ids): + pass + + @abstractmethod + def get_thumbnail_path(self, project_name, thumbnail_id): + pass + # Load action items @abstractmethod def get_versions_action_items(self, project_name, version_ids): diff --git a/openpype/tools/ayon_loader/control.py b/openpype/tools/ayon_loader/control.py index 622780cc001..bc799ce60e0 100644 --- a/openpype/tools/ayon_loader/control.py +++ b/openpype/tools/ayon_loader/control.py @@ -10,6 +10,7 @@ HierarchyModel, NestedCacheItem, CacheItem, + ThumbnailsModel, ) from .abstract import AbstractController @@ -38,6 +39,7 @@ def __init__(self, host=None): self._hierarchy_model = HierarchyModel(self) self._products_model = ProductsModel(self) self._loader_actions_model = LoaderActionsModel(self) + self._thumbnails_model = ThumbnailsModel() @property def log(self): @@ -69,6 +71,7 @@ def reset(self): self._hierarchy_model.reset() self._loader_actions_model.reset() self._projects_model.refresh() + self._thumbnails_model.reset() self._emit_event("controller.reset.finished") @@ -101,6 +104,19 @@ def get_representation_items( def get_folder_entity(self, project_name, folder_id): self._hierarchy_model.get_folder_entity(project_name, folder_id) + def get_folder_thumbnail_ids(self, project_name, folder_ids): + return self._thumbnails_model.get_folder_thumbnail_ids( + project_name, folder_ids) + + def get_version_thumbnail_ids(self, project_name, version_ids): + return self._thumbnails_model.get_version_thumbnail_ids( + project_name, version_ids) + + def get_thumbnail_path(self, project_name, thumbnail_id): + return self._thumbnails_model.get_thumbnail_path( + project_name, thumbnail_id + ) + def get_versions_action_items(self, project_name, version_ids): return self._loader_actions_model.get_versions_action_items( project_name, version_ids) diff --git a/openpype/tools/ayon_loader/ui/window.py b/openpype/tools/ayon_loader/ui/window.py index 84bf9660530..3bd1e493b54 100644 --- a/openpype/tools/ayon_loader/ui/window.py +++ b/openpype/tools/ayon_loader/ui/window.py @@ -2,7 +2,11 @@ from openpype.resources import get_openpype_icon_filepath from openpype.style import load_stylesheet -from openpype.tools.utils import PlaceholderLineEdit, ErrorMessageBox +from openpype.tools.utils import ( + PlaceholderLineEdit, + ErrorMessageBox, + ThumbnailPainterWidget, +) from openpype.tools.utils.lib import center_window from openpype.tools.ayon_utils.widgets import ProjectsCombobox from openpype.tools.ayon_loader.control import LoaderController @@ -144,13 +148,20 @@ def __init__(self, controller=None, parent=None): right_panel_splitter = QtWidgets.QSplitter(main_splitter) right_panel_splitter.setOrientation(QtCore.Qt.Vertical) + thumbnails_widget = ThumbnailPainterWidget(right_panel_splitter) + info_widget = InfoWidget(controller, right_panel_splitter) repre_widget = RepresentationsWidget(controller, right_panel_splitter) + right_panel_splitter.addWidget(thumbnails_widget) right_panel_splitter.addWidget(info_widget) right_panel_splitter.addWidget(repre_widget) + right_panel_splitter.setStretchFactor(0, 1) + right_panel_splitter.setStretchFactor(1, 1) + right_panel_splitter.setStretchFactor(2, 2) + main_splitter.addWidget(context_splitter) main_splitter.addWidget(products_wrap_widget) main_splitter.addWidget(right_panel_splitter) @@ -189,6 +200,18 @@ def __init__(self, controller=None, parent=None): "load.finished", self._on_load_finished, ) + controller.register_event_callback( + "selection.project.changed", + self._on_project_selection_changed, + ) + controller.register_event_callback( + "selection.folders.changed", + self._on_folders_selection_changed, + ) + controller.register_event_callback( + "selection.versions.changed", + self._on_versions_selection_changed, + ) self._main_splitter = main_splitter self._projects_combobox = projects_combobox @@ -202,13 +225,19 @@ def __init__(self, controller=None, parent=None): self._product_group_checkbox = product_group_checkbox self._products_widget = products_widget + self._right_panel_splitter = right_panel_splitter + self._thumbnails_widget = thumbnails_widget self._info_widget = info_widget + self._repre_widget = repre_widget self._controller = controller self._first_show = True self._reset_on_show = True self._show_counter = 0 self._show_timer = show_timer + self._selected_project_name = None + self._selected_folder_ids = set() + self._selected_version_ids = set() def showEvent(self, event): super(LoaderWindow, self).showEvent(event) @@ -222,6 +251,7 @@ def _on_first_show(self): self._first_show = False self.resize(1800, 900) self._main_splitter.setSizes([350, 1000, 350]) + self._right_panel_splitter.setSizes([250, 325, 325]) self.setStyleSheet(load_stylesheet()) center_window(self) self._controller.reset() @@ -272,3 +302,44 @@ def _on_load_finished(self, event): box = LoadErrorMessageBox(error_info, self) box.show() + + def _on_project_selection_changed(self, event): + self._selected_project_name = event["project_name"] + + def _on_folders_selection_changed(self, event): + self._selected_folder_ids = set(event["folder_ids"]) + self._update_thumbnails() + + def _on_versions_selection_changed(self, event): + self._selected_version_ids = set(event["version_ids"]) + self._update_thumbnails() + + def _update_thumbnails(self): + project_name = self._selected_project_name + thumbnail_ids = set() + if self._selected_version_ids: + thumbnail_id_by_entity_id = self._controller.get_version_thumbnail_ids( + project_name, + self._selected_version_ids + ) + thumbnail_ids = set(thumbnail_id_by_entity_id.values()) + elif self._selected_folder_ids: + thumbnail_id_by_entity_id = self._controller.get_folder_thumbnail_ids( + project_name, + self._selected_folder_ids + ) + thumbnail_ids = set(thumbnail_id_by_entity_id.values()) + + thumbnail_ids.discard(None) + + if not thumbnail_ids: + self._thumbnails_widget.set_current_thumbnails(None) + return + + thumbnail_paths = set() + for thumbnail_id in thumbnail_ids: + thumbnail_path = self._controller.get_thumbnail_path( + project_name, thumbnail_id) + thumbnail_paths.add(thumbnail_path) + thumbnail_paths.discard(None) + self._thumbnails_widget.set_current_thumbnail_paths(thumbnail_paths) From 46e08bfdab567c8bdd7b5a49d5682c389391ebc3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 6 Oct 2023 14:23:21 +0200 Subject: [PATCH 33/69] fix FolderItem args docstring --- openpype/tools/ayon_utils/models/hierarchy.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/openpype/tools/ayon_utils/models/hierarchy.py b/openpype/tools/ayon_utils/models/hierarchy.py index 47fac08e99d..6c30d22f3aa 100644 --- a/openpype/tools/ayon_utils/models/hierarchy.py +++ b/openpype/tools/ayon_utils/models/hierarchy.py @@ -29,9 +29,8 @@ class FolderItem: parent_id (Union[str, None]): Parent folder id. If 'None' then project is parent. name (str): Name of folder. - label (str): Folder label. - icon_name (str): Name of icon from font awesome. - icon_color (str): Hex color string that will be used for icon. + label (Union[str, None]): Folder label. + icon (Union[dict[str, Any], None]): Icon definition. """ def __init__( From 51f2a52fb2f95e27c439fc41bdbd65a616d33bbb Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 6 Oct 2023 14:36:19 +0200 Subject: [PATCH 34/69] bypass bug in ayon_api --- openpype/pipeline/thumbnail.py | 10 +++++++--- openpype/tools/ayon_utils/models/thumbnails.py | 9 +++++---- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/openpype/pipeline/thumbnail.py b/openpype/pipeline/thumbnail.py index b2b3679450d..63c55d0c194 100644 --- a/openpype/pipeline/thumbnail.py +++ b/openpype/pipeline/thumbnail.py @@ -166,8 +166,12 @@ def process(self, thumbnail_entity, thumbnail_type): # This is new way how thumbnails can be received from server # - output is 'ThumbnailContent' object - if hasattr(ayon_api, "get_thumbnail_by_id"): - result = ayon_api.get_thumbnail_by_id(thumbnail_id) + # NOTE Use 'get_server_api_connection' because public function + # 'get_thumbnail_by_id' does not return output of 'ServerAPI' + # method. + con = ayon_api.get_server_api_connection() + if hasattr(con, "get_thumbnail_by_id"): + result = con.get_thumbnail_by_id(thumbnail_id) if result.is_valid: filepath = cache.store_thumbnail( project_name, @@ -178,7 +182,7 @@ def process(self, thumbnail_entity, thumbnail_type): else: # Backwards compatibility for ayon api where 'get_thumbnail_by_id' # is not implemented and output is filepath - filepath = ayon_api.get_thumbnail( + filepath = con.get_thumbnail( project_name, entity_type, entity_id, thumbnail_id ) diff --git a/openpype/tools/ayon_utils/models/thumbnails.py b/openpype/tools/ayon_utils/models/thumbnails.py index ea7a4174efb..f19883e352c 100644 --- a/openpype/tools/ayon_utils/models/thumbnails.py +++ b/openpype/tools/ayon_utils/models/thumbnails.py @@ -72,11 +72,12 @@ def _get_thumbnail_path(self, project_name, thumbnail_id): project_cache[thumbnail_id] = filepath return filepath - result = ayon_api.get_thumbnail_by_id(project_name, thumbnail_id) + # 'ayon_api' had a bug, public function + # 'get_thumbnail_by_id' did not return output of + # 'ServerAPI' method. + con = ayon_api.get_server_api_connection() + result = con.get_thumbnail_by_id(project_name, thumbnail_id) if result is None: - # Caused by bug in 'ayon_api'. Public function - # 'get_thumbnail_by_id' does not return output of - # 'ServerAPI' method. pass elif result.is_valid: From 00f7e074a3d6f497ab345becede1d66a831cc21d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 6 Oct 2023 17:54:54 +0200 Subject: [PATCH 35/69] fix sorting of folders --- openpype/tools/ayon_loader/ui/folders_widget.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/tools/ayon_loader/ui/folders_widget.py b/openpype/tools/ayon_loader/ui/folders_widget.py index 7bb7200f1e9..e3760c101c5 100644 --- a/openpype/tools/ayon_loader/ui/folders_widget.py +++ b/openpype/tools/ayon_loader/ui/folders_widget.py @@ -275,6 +275,7 @@ def __init__(self, controller, parent, handle_expected_selection=False): folders_model = LoaderFoldersModel(controller) folders_proxy_model = RecursiveSortFilterProxyModel() folders_proxy_model.setSourceModel(folders_model) + folders_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) folders_label_delegate = UnderlinesFolderDelegate(folders_view) From f6f65f69e6e9445fb91ba35921038ae653d26fa3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 6 Oct 2023 19:29:00 +0200 Subject: [PATCH 36/69] added refresh button --- openpype/tools/ayon_loader/control.py | 3 ++- openpype/tools/ayon_loader/ui/window.py | 23 +++++++++++++++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/openpype/tools/ayon_loader/control.py b/openpype/tools/ayon_loader/control.py index bc799ce60e0..fa32b41986a 100644 --- a/openpype/tools/ayon_loader/control.py +++ b/openpype/tools/ayon_loader/control.py @@ -30,7 +30,8 @@ def __init__(self, host=None): self._event_system = self._create_event_system() - self._project_anatomy_cache = NestedCacheItem(levels=1) + self._project_anatomy_cache = NestedCacheItem( + levels=1, lifetime=60) self._loaded_products_cache = CacheItem( default_factory=set, lifetime=60) diff --git a/openpype/tools/ayon_loader/ui/window.py b/openpype/tools/ayon_loader/ui/window.py index 3bd1e493b54..8f8ac582415 100644 --- a/openpype/tools/ayon_loader/ui/window.py +++ b/openpype/tools/ayon_loader/ui/window.py @@ -6,6 +6,7 @@ PlaceholderLineEdit, ErrorMessageBox, ThumbnailPainterWidget, + RefreshButton, ) from openpype.tools.utils.lib import center_window from openpype.tools.ayon_utils.widgets import ProjectsCombobox @@ -101,9 +102,20 @@ def __init__(self, controller=None, parent=None): # Context selection widget context_widget = QtWidgets.QWidget(context_splitter) - projects_combobox = ProjectsCombobox(controller, context_widget) + context_top_widget = QtWidgets.QWidget(context_widget) + projects_combobox = ProjectsCombobox( + controller, + context_top_widget, + ) projects_combobox.set_select_item_visible(True) + refresh_btn = RefreshButton(context_top_widget) + + context_top_layout = QtWidgets.QHBoxLayout(context_top_widget) + context_top_layout.setContentsMargins(0, 0, 0, 0,) + context_top_layout.addWidget(projects_combobox, 1) + context_top_layout.addWidget(refresh_btn, 0) + folders_filter_input = PlaceholderLineEdit(context_widget) folders_filter_input.setPlaceholderText("Folder name filter...") @@ -113,7 +125,7 @@ def __init__(self, controller=None, parent=None): context_layout = QtWidgets.QVBoxLayout(context_widget) context_layout.setContentsMargins(0, 0, 0, 0) - context_layout.addWidget(projects_combobox, 0) + context_layout.addWidget(context_top_widget, 0) context_layout.addWidget(folders_filter_input, 0) context_layout.addWidget(folders_widget, 1) @@ -196,6 +208,9 @@ def __init__(self, controller=None, parent=None): products_widget.selection_changed.connect( self._on_products_selection_change ) + refresh_btn.clicked.connect( + self._on_refresh_click + ) controller.register_event_callback( "load.finished", self._on_load_finished, @@ -214,6 +229,7 @@ def __init__(self, controller=None, parent=None): ) self._main_splitter = main_splitter + self._refresh_btn = refresh_btn self._projects_combobox = projects_combobox self._folders_filter_input = folders_filter_input @@ -295,6 +311,9 @@ def _on_products_selection_change(self): items ) + def _on_refresh_click(self): + self._controller.reset() + def _on_load_finished(self, event): error_info = event["error_info"] if not error_info: From a9f9e33aecc2d7bbcfc975077bc5a6e368c3c9fb Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 6 Oct 2023 19:38:34 +0200 Subject: [PATCH 37/69] added expected selection and go to current context --- openpype/tools/ayon_loader/abstract.py | 20 ++++++ openpype/tools/ayon_loader/control.py | 94 ++++++++++++++++++++++++- openpype/tools/ayon_loader/ui/window.py | 24 +++++++ 3 files changed, 137 insertions(+), 1 deletion(-) diff --git a/openpype/tools/ayon_loader/abstract.py b/openpype/tools/ayon_loader/abstract.py index 65598d8e8d7..8ad88ff2620 100644 --- a/openpype/tools/ayon_loader/abstract.py +++ b/openpype/tools/ayon_loader/abstract.py @@ -179,6 +179,9 @@ def from_data(cls, data): @six.add_metaclass(ABCMeta) class AbstractController: + @abstractmethod + def is_standard_projects_filter_enabled(self): + pass @abstractmethod def emit_event(self, topic, data=None, source=None): @@ -192,6 +195,23 @@ def register_event_callback(self, topic, callback): def reset(self): pass + # Expected selection helpers + @abstractmethod + def get_expected_selection_data(self): + pass + + @abstractmethod + def set_expected_selection(self, project_name, folder_id): + pass + + @abstractmethod + def expected_project_selected(self, project_name): + pass + + @abstractmethod + def expected_folder_selected(self, folder_id): + pass + # Model wrapper calls @abstractmethod def get_project_items(self): diff --git a/openpype/tools/ayon_loader/control.py b/openpype/tools/ayon_loader/control.py index fa32b41986a..2bb615bd466 100644 --- a/openpype/tools/ayon_loader/control.py +++ b/openpype/tools/ayon_loader/control.py @@ -17,6 +17,71 @@ from .models import SelectionModel, ProductsModel, LoaderActionsModel +class ExpectedSelection: + def __init__(self, controller): + self._project_name = None + self._folder_id = None + + self._project_selected = True + self._folder_selected = True + + self._controller = controller + + def _emit_change(self): + self._controller.emit_event( + "expected_selection_changed", + self.get_expected_selection_data(), + ) + + def set_expected_selection(self, project_name, folder_id): + self._project_name = project_name + self._folder_id = folder_id + + self._project_selected = False + self._folder_selected = False + self._emit_change() + + def get_expected_selection_data(self): + project_current = False + folder_current = False + if not self._project_selected: + project_current = True + elif not self._folder_selected: + folder_current = True + return { + "project": { + "name": self._project_name, + "current": project_current, + "selected": self._project_selected, + }, + "folder": { + "id": self._folder_id, + "current": folder_current, + "selected": self._folder_selected, + }, + } + + def is_expected_project_selected(self, project_name): + return project_name == self._project_name and self._project_selected + + def is_expected_folder_selected(self, folder_id): + return folder_id == self._folder_id and self._folder_selected + + def expected_project_selected(self, project_name): + if project_name != self._project_name: + return False + self._project_selected = True + self._emit_change() + return True + + def expected_folder_selected(self, folder_id): + if folder_id != self._folder_id: + return False + self._folder_selected = True + self._emit_change() + return True + + class LoaderController(AbstractController): """ @@ -36,6 +101,7 @@ def __init__(self, host=None): default_factory=set, lifetime=60) self._selection_model = SelectionModel(self) + self._expected_selection = ExpectedSelection(self) self._projects_model = ProjectsModel(self) self._hierarchy_model = HierarchyModel(self) self._products_model = ProductsModel(self) @@ -65,17 +131,43 @@ def register_event_callback(self, topic, callback): def reset(self): self._emit_event("controller.reset.started") + project_name = self.get_selected_project_name() + folder_ids = self.get_selected_folder_ids() + self._project_anatomy_cache.reset() self._loaded_products_cache.reset() self._products_model.reset() self._hierarchy_model.reset() self._loader_actions_model.reset() - self._projects_model.refresh() + self._projects_model.reset() self._thumbnails_model.reset() + self._projects_model.refresh() + + if not project_name and not folder_ids: + context = self.get_current_context() + project_name = context["project_name"] + folder_id = context["folder_id"] + self.set_expected_selection(project_name, folder_id) + self._emit_event("controller.reset.finished") + # Expected selection helpers + def get_expected_selection_data(self): + return self._expected_selection.get_expected_selection_data() + + def set_expected_selection(self, project_name, folder_id): + self._expected_selection.set_expected_selection( + project_name, folder_id + ) + + def expected_project_selected(self, project_name): + self._expected_selection.expected_project_selected(project_name) + + def expected_folder_selected(self, folder_id): + self._expected_selection.expected_folder_selected(folder_id) + # Entity model wrappers def get_project_items(self, sender=None): return self._projects_model.get_project_items(sender) diff --git a/openpype/tools/ayon_loader/ui/window.py b/openpype/tools/ayon_loader/ui/window.py index 8f8ac582415..bb53bf513d6 100644 --- a/openpype/tools/ayon_loader/ui/window.py +++ b/openpype/tools/ayon_loader/ui/window.py @@ -7,6 +7,7 @@ ErrorMessageBox, ThumbnailPainterWidget, RefreshButton, + GoToCurrentButton, ) from openpype.tools.utils.lib import center_window from openpype.tools.ayon_utils.widgets import ProjectsCombobox @@ -106,14 +107,17 @@ def __init__(self, controller=None, parent=None): projects_combobox = ProjectsCombobox( controller, context_top_widget, + handle_expected_selection=True ) projects_combobox.set_select_item_visible(True) + go_to_current_btn = GoToCurrentButton(context_top_widget) refresh_btn = RefreshButton(context_top_widget) context_top_layout = QtWidgets.QHBoxLayout(context_top_widget) context_top_layout.setContentsMargins(0, 0, 0, 0,) context_top_layout.addWidget(projects_combobox, 1) + context_top_layout.addWidget(go_to_current_btn, 0) context_top_layout.addWidget(refresh_btn, 0) folders_filter_input = PlaceholderLineEdit(context_widget) @@ -208,6 +212,9 @@ def __init__(self, controller=None, parent=None): products_widget.selection_changed.connect( self._on_products_selection_change ) + go_to_current_btn.clicked.connect( + self._on_go_to_current_context_click + ) refresh_btn.clicked.connect( self._on_refresh_click ) @@ -227,8 +234,14 @@ def __init__(self, controller=None, parent=None): "selection.versions.changed", self._on_versions_selection_changed, ) + controller.register_event_callback( + "controller.reset.finished", + self._on_controller_reset, + ) self._main_splitter = main_splitter + + self._go_to_current_btn = go_to_current_btn self._refresh_btn = refresh_btn self._projects_combobox = projects_combobox @@ -311,9 +324,20 @@ def _on_products_selection_change(self): items ) + def _on_go_to_current_context_click(self): + context = self._controller.get_current_context() + self._controller.set_expected_selection( + context["project_name"], + context["folder_id"], + ) + def _on_refresh_click(self): self._controller.reset() + def _on_controller_reset(self): + context = self._controller.get_current_context() + self._go_to_current_btn.setVisible(bool(context["project_name"])) + def _on_load_finished(self, event): error_info = event["error_info"] if not error_info: From 822a0f8ad5d68601aa3c7e70d639628b175d605f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 6 Oct 2023 19:38:49 +0200 Subject: [PATCH 38/69] added information if project item is library project --- openpype/tools/ayon_utils/models/projects.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/openpype/tools/ayon_utils/models/projects.py b/openpype/tools/ayon_utils/models/projects.py index ae3eeecea40..ddf3fdaf83d 100644 --- a/openpype/tools/ayon_utils/models/projects.py +++ b/openpype/tools/ayon_utils/models/projects.py @@ -29,9 +29,10 @@ class ProjectItem: is parent. """ - def __init__(self, name, active, icon=None): + def __init__(self, name, active, is_library, icon=None): self.name = name self.active = active + self.is_library = is_library if icon is None: icon = { "type": "awesome-font", @@ -50,6 +51,7 @@ def to_data(self): return { "name": self.name, "active": self.active, + "is_library": self.is_library, "icon": self.icon, } @@ -78,7 +80,7 @@ def _get_project_items_from_entitiy(projects): """ return [ - ProjectItem(project["name"], project["active"]) + ProjectItem(project["name"], project["active"], project["library"]) for project in projects ] @@ -141,5 +143,5 @@ def _refresh_projects_cache(self, sender=None): self._projects_cache.update_data(project_items) def _query_projects(self): - projects = ayon_api.get_projects(fields=["name", "active"]) + projects = ayon_api.get_projects(fields=["name", "active", "library"]) return _get_project_items_from_entitiy(projects) From 99b6e99d9c58db54f8c598e86327942b1abf4596 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 6 Oct 2023 19:39:59 +0200 Subject: [PATCH 39/69] added more filtering options to projects widget --- .../ayon_utils/widgets/projects_widget.py | 51 +++++++++++++++++-- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/openpype/tools/ayon_utils/widgets/projects_widget.py b/openpype/tools/ayon_utils/widgets/projects_widget.py index 08166c4c9dc..d60f8240041 100644 --- a/openpype/tools/ayon_utils/widgets/projects_widget.py +++ b/openpype/tools/ayon_utils/widgets/projects_widget.py @@ -149,6 +149,8 @@ class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel): def __init__(self, *args, **kwargs): super(ProjectSortFilterProxy, self).__init__(*args, **kwargs) self._filter_inactive = True + self._filter_standard = False + self._filter_library = False # Disable case sensitivity self.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) @@ -175,19 +177,40 @@ def filterAcceptsRow(self, source_row, source_parent): project_name = index.data(PROJECT_NAME_ROLE) if project_name is None: return True + string_pattern = self.filterRegularExpression().pattern() + if string_pattern: + return string_pattern.lower() in project_name.lower() + + # Current project keep always visible + default = super(ProjectSortFilterProxy, self).filterAcceptsRow( + source_row, source_parent + ) + if not default: + return default + + # Make sure current project is visible + if index.data(PROJECT_IS_CURRENT_ROLE): + return True + if ( self._filter_inactive and not index.data(PROJECT_IS_ACTIVE_ROLE) ): return False - if string_pattern: - return string_pattern.lower() in project_name.lower() + if ( + self._filter_standard + and not index.data(PROJECT_IS_LIBRARY_ROLE) + ): + return False - return super(ProjectSortFilterProxy, self).filterAcceptsRow( - source_row, source_parent - ) + if ( + self._filter_library + and index.data(PROJECT_IS_LIBRARY_ROLE) + ): + return False + return True def _custom_index_filter(self, index): return bool(index.data(PROJECT_IS_ACTIVE_ROLE)) @@ -201,6 +224,18 @@ def set_active_filter_enabled(self, enabled): self._filter_inactive = enabled self.invalidateFilter() + def set_library_filter_enabled(self, enabled): + if self._filter_library == enabled: + return + self._filter_library = enabled + self.invalidateFilter() + + def set_standard_filter_enabled(self, enabled): + if self._filter_standard == enabled: + return + self._filter_standard = enabled + self.invalidateFilter() + class ProjectsCombobox(QtWidgets.QWidget): def __init__(self, controller, parent, handle_expected_selection=False): @@ -309,6 +344,12 @@ def is_active_filter_enabled(self): def set_active_filter_enabled(self, enabled): return self._projects_proxy_model.set_active_filter_enabled(enabled) + def set_standard_filter_enabled(self, enabled): + return self._projects_proxy_model.set_standard_filter_enabled(enabled) + + def set_library_filter_enabled(self, enabled): + return self._projects_proxy_model.set_library_filter_enabled(enabled) + def _on_current_index_changed(self, idx): if not self._listen_selection_change: return From 0cd5f5ce8f2430e60317482d5111f3b5ffe49ecd Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 6 Oct 2023 19:40:47 +0200 Subject: [PATCH 40/69] added missing information abou is library to model items --- openpype/tools/ayon_utils/widgets/projects_widget.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/tools/ayon_utils/widgets/projects_widget.py b/openpype/tools/ayon_utils/widgets/projects_widget.py index d60f8240041..70211c54039 100644 --- a/openpype/tools/ayon_utils/widgets/projects_widget.py +++ b/openpype/tools/ayon_utils/widgets/projects_widget.py @@ -5,6 +5,7 @@ PROJECT_NAME_ROLE = QtCore.Qt.UserRole + 1 PROJECT_IS_ACTIVE_ROLE = QtCore.Qt.UserRole + 2 +PROJECT_IS_LIBRARY_ROLE = QtCore.Qt.UserRole + 3 class ProjectsModel(QtGui.QStandardItemModel): @@ -129,6 +130,7 @@ def _fill_items(self, project_items): item.setData(icon, QtCore.Qt.DecorationRole) item.setData(project_name, PROJECT_NAME_ROLE) item.setData(project_item.active, PROJECT_IS_ACTIVE_ROLE) + item.setData(project_item.is_library, PROJECT_IS_LIBRARY_ROLE) self._project_items[project_name] = item root_item = self.invisibleRootItem() From e097017bbb323fa52e73cd9b7bdfb66ba479bcc0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 6 Oct 2023 19:44:41 +0200 Subject: [PATCH 41/69] remove select project item on selection change --- .../ayon_utils/widgets/projects_widget.py | 104 ++++++++++++++---- 1 file changed, 84 insertions(+), 20 deletions(-) diff --git a/openpype/tools/ayon_utils/widgets/projects_widget.py b/openpype/tools/ayon_utils/widgets/projects_widget.py index 70211c54039..1411deba66e 100644 --- a/openpype/tools/ayon_utils/widgets/projects_widget.py +++ b/openpype/tools/ayon_utils/widgets/projects_widget.py @@ -6,6 +6,7 @@ PROJECT_NAME_ROLE = QtCore.Qt.UserRole + 1 PROJECT_IS_ACTIVE_ROLE = QtCore.Qt.UserRole + 2 PROJECT_IS_LIBRARY_ROLE = QtCore.Qt.UserRole + 3 +PROJECT_IS_CURRENT_ROLE = QtCore.Qt.UserRole + 4 class ProjectsModel(QtGui.QStandardItemModel): @@ -25,6 +26,10 @@ def __init__(self, controller): self._select_item_visible = None + self._current_context_project = None + + self._selected_project = None + self._is_refreshing = False self._refresh_thread = None @@ -42,23 +47,46 @@ def set_select_item_visible(self, visible): if self._select_item_visible is visible: return self._select_item_visible = visible - self._add_select_item() + + if self._selected_project is None: + self._add_select_item() + + def set_selected_project(self, project_name): + if not self._select_item_visible: + return + + self._selected_project = project_name + if project_name is None: + self._add_select_item() + else: + self._remove_select_item() + + def set_current_context_project(self, project_name): + if project_name == self._current_context_project: + return + prev_item = self._project_items.get(self._current_context_project) + if prev_item is not None: + prev_item.setData(False, PROJECT_IS_CURRENT_ROLE) + self._current_context_project = project_name + item = self._project_items.get(project_name) + if item is not None: + item.setData(True, PROJECT_IS_CURRENT_ROLE) def _add_empty_item(self): + if self._empty_item_added: + return + self._empty_item_added = True item = self._get_empty_item() - if not self._empty_item_added: - root_item = self.invisibleRootItem() - root_item.appendRow(item) - self._empty_item_added = True + root_item = self.invisibleRootItem() + root_item.appendRow(item) def _remove_empty_item(self): if not self._empty_item_added: return - + self._empty_item_added = False root_item = self.invisibleRootItem() item = self._get_empty_item() root_item.takeRow(item.row()) - self._empty_item_added = False def _get_empty_item(self): if self._empty_item is None: @@ -68,20 +96,20 @@ def _get_empty_item(self): return self._empty_item def _add_select_item(self): + if self._select_item_added: + return + self._select_item_added = True item = self._get_select_item() - if not self._select_item_added: - root_item = self.invisibleRootItem() - root_item.appendRow(item) - self._select_item_added = True + root_item = self.invisibleRootItem() + root_item.appendRow(item) def _remove_select_item(self): if not self._select_item_added: return - + self._select_item_added = False root_item = self.invisibleRootItem() item = self._get_select_item() root_item.takeRow(item.row()) - self._select_item_added = False def _get_select_item(self): if self._select_item is None: @@ -115,11 +143,30 @@ def _refresh_finished(self): self.refreshed.emit() def _fill_items(self, project_items): - items_to_remove = set(self._project_items.keys()) + new_project_names = { + project_item.name + for project_item in project_items + } + + # Handle "Select item" visibility + if self._select_item_visible: + # Add select project. if previously selected project is not in + # project items + if self._selected_project not in new_project_names: + self._add_select_item() + else: + self._remove_select_item() + + root_item = self.invisibleRootItem() + + items_to_remove = set(self._project_items.keys()) - new_project_names + for project_name in items_to_remove: + item = self._project_items.pop(project_name) + root_item.takeRow(item.row()) + new_items = [] for project_item in project_items: project_name = project_item.name - items_to_remove.discard(project_name) item = self._project_items.get(project_name) if item is None: item = QtGui.QStandardItem() @@ -131,20 +178,20 @@ def _fill_items(self, project_items): item.setData(project_name, PROJECT_NAME_ROLE) item.setData(project_item.active, PROJECT_IS_ACTIVE_ROLE) item.setData(project_item.is_library, PROJECT_IS_LIBRARY_ROLE) + is_current = project_name == self._current_context_project + item.setData(is_current, PROJECT_IS_CURRENT_ROLE) self._project_items[project_name] = item - root_item = self.invisibleRootItem() if new_items: root_item.appendRows(new_items) - for project_name in items_to_remove: - item = self._project_items.pop(project_name) - root_item.removeRow(item.row()) - if self.has_content(): + # Make sure "No projects" item is removed self._remove_empty_item() else: + # Keep only "No projects" item self._add_empty_item() + self._remove_select_item() class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel): @@ -276,6 +323,7 @@ def __init__(self, controller, parent, handle_expected_selection=False): self._controller = controller self._listen_selection_change = True + self._select_item_visible = False self._handle_expected_selection = handle_expected_selection self._expected_selection = None @@ -337,8 +385,21 @@ def get_current_project_name(self): return None return self._projects_combobox.itemData(idx, PROJECT_NAME_ROLE) + def _update_select_item_visiblity(self, **kwargs): + if not self._select_item_visible: + return + if "project_name" not in kwargs: + project_name = self.get_current_project_name() + else: + project_name = kwargs.get("project_name") + + # Hide the item if a project is selected + self._projects_model.set_selected_project(project_name) + def set_select_item_visible(self, visible): + self._select_item_visible = visible self._projects_model.set_select_item_visible(visible) + self._update_select_item_visiblity() def is_active_filter_enabled(self): return self._projects_proxy_model.is_active_filter_enabled() @@ -357,12 +418,15 @@ def _on_current_index_changed(self, idx): return project_name = self._projects_combobox.itemData( idx, PROJECT_NAME_ROLE) + self._update_select_item_visiblity(project_name=project_name) self._controller.set_selected_project(project_name) def _on_model_refresh(self): self._projects_proxy_model.sort(0) + self._projects_proxy_model.invalidateFilter() if self._expected_selection: self._set_expected_selection() + self._update_select_item_visiblity() def _on_projects_refresh_finished(self, event): if event["sender"] != PROJECTS_MODEL_SENDER: From f0be388468ae1147125227146b56cae2b722ee5c Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 6 Oct 2023 19:45:08 +0200 Subject: [PATCH 42/69] filter out non library projects --- openpype/tools/ayon_loader/control.py | 3 +++ openpype/tools/ayon_loader/ui/window.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/openpype/tools/ayon_loader/control.py b/openpype/tools/ayon_loader/control.py index 2bb615bd466..1ed62271236 100644 --- a/openpype/tools/ayon_loader/control.py +++ b/openpype/tools/ayon_loader/control.py @@ -114,6 +114,9 @@ def log(self): self._log = logging.getLogger(self.__class__.__name__) return self._log + def is_standard_projects_filter_enabled(self): + return self._host is not None + # --------------------------------- # Implementation of abstract methods # --------------------------------- diff --git a/openpype/tools/ayon_loader/ui/window.py b/openpype/tools/ayon_loader/ui/window.py index bb53bf513d6..1beca4a6cc2 100644 --- a/openpype/tools/ayon_loader/ui/window.py +++ b/openpype/tools/ayon_loader/ui/window.py @@ -110,6 +110,9 @@ def __init__(self, controller=None, parent=None): handle_expected_selection=True ) projects_combobox.set_select_item_visible(True) + projects_combobox.set_standard_filter_enabled( + controller.is_standard_projects_filter_enabled() + ) go_to_current_btn = GoToCurrentButton(context_top_widget) refresh_btn = RefreshButton(context_top_widget) From 06f9d87c6e10da0c6bbb1fed5c57ebec22649365 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 6 Oct 2023 19:50:36 +0200 Subject: [PATCH 43/69] set current context project to project combobox --- openpype/tools/ayon_loader/abstract.py | 8 ++++---- openpype/tools/ayon_loader/control.py | 6 +++--- openpype/tools/ayon_loader/ui/window.py | 4 +++- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/openpype/tools/ayon_loader/abstract.py b/openpype/tools/ayon_loader/abstract.py index 8ad88ff2620..c472e788e51 100644 --- a/openpype/tools/ayon_loader/abstract.py +++ b/openpype/tools/ayon_loader/abstract.py @@ -179,10 +179,6 @@ def from_data(cls, data): @six.add_metaclass(ABCMeta) class AbstractController: - @abstractmethod - def is_standard_projects_filter_enabled(self): - pass - @abstractmethod def emit_event(self, topic, data=None, source=None): pass @@ -351,6 +347,10 @@ def is_loaded_products_supported(self): pass + @abstractmethod + def is_standard_projects_filter_enabled(self): + pass + @abstractmethod def get_loaded_product_ids(self): """Return set of loaded product ids. diff --git a/openpype/tools/ayon_loader/control.py b/openpype/tools/ayon_loader/control.py index 1ed62271236..81fd29eb0e5 100644 --- a/openpype/tools/ayon_loader/control.py +++ b/openpype/tools/ayon_loader/control.py @@ -114,9 +114,6 @@ def log(self): self._log = logging.getLogger(self.__class__.__name__) return self._log - def is_standard_projects_filter_enabled(self): - return self._host is not None - # --------------------------------- # Implementation of abstract methods # --------------------------------- @@ -326,6 +323,9 @@ def get_loaded_product_ids(self): def is_loaded_products_supported(self): return self._host is not None + def is_standard_projects_filter_enabled(self): + return self._host is not None + def _get_project_anatomy(self, project_name): if not project_name: return None diff --git a/openpype/tools/ayon_loader/ui/window.py b/openpype/tools/ayon_loader/ui/window.py index 1beca4a6cc2..7258a883454 100644 --- a/openpype/tools/ayon_loader/ui/window.py +++ b/openpype/tools/ayon_loader/ui/window.py @@ -339,7 +339,9 @@ def _on_refresh_click(self): def _on_controller_reset(self): context = self._controller.get_current_context() - self._go_to_current_btn.setVisible(bool(context["project_name"])) + project_name = context["project_name"] + self._go_to_current_btn.setVisible(bool(project_name)) + self._projects_combobox.set_current_context_project(project_name) def _on_load_finished(self, event): error_info = event["error_info"] From fbf42c8ffa14cde27446c73317e66343cd75bc24 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 6 Oct 2023 19:50:51 +0200 Subject: [PATCH 44/69] change window title --- openpype/tools/ayon_loader/ui/window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/ayon_loader/ui/window.py b/openpype/tools/ayon_loader/ui/window.py index 7258a883454..1e7b0710a90 100644 --- a/openpype/tools/ayon_loader/ui/window.py +++ b/openpype/tools/ayon_loader/ui/window.py @@ -87,7 +87,7 @@ def __init__(self, controller=None, parent=None): icon = QtGui.QIcon(get_openpype_icon_filepath()) self.setWindowIcon(icon) - self.setWindowTitle("Loader") + self.setWindowTitle("AYON Loader") self.setFocusPolicy(QtCore.Qt.StrongFocus) self.setAttribute(QtCore.Qt.WA_DeleteOnClose, False) self.setWindowFlags(self.windowFlags() | QtCore.Qt.Window) From 93719fe32c8f70cd4b3671e43793e0de31f491a6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 9 Oct 2023 10:37:33 +0200 Subject: [PATCH 45/69] fix hero version queries --- openpype/tools/ayon_loader/models/actions.py | 42 +++++++++++++++++--- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/openpype/tools/ayon_loader/models/actions.py b/openpype/tools/ayon_loader/models/actions.py index e3276373e6c..1b781b9f758 100644 --- a/openpype/tools/ayon_loader/models/actions.py +++ b/openpype/tools/ayon_loader/models/actions.py @@ -387,6 +387,39 @@ def _actions_sorter(self, action_item): return action_item.order, action_item.label + def _get_version_docs(self, project_name, version_ids): + """Get version documents for given version ids. + + This function also handles hero versions and copies data from + source version to it. + + Todos: + Remove this function when this is completely rewritten to + use AYON calls. + """ + + version_docs = list(get_versions( + project_name, version_ids=version_ids, hero=True + )) + hero_versions_by_src_id = collections.defaultdict(list) + src_hero_version = set() + for version_doc in version_docs: + if version_doc["type"] != "hero": + continue + version_id = "" + src_hero_version.add(version_id) + hero_versions_by_src_id[version_id].append(version_doc) + + src_versions = [] + if src_hero_version: + src_versions = get_versions(project_name, version_ids=version_ids) + for src_version in src_versions: + src_version_id = src_version["_id"] + for hero_version in hero_versions_by_src_id[src_version_id]: + hero_version["data"] = copy.deepcopy(src_version["data"]) + + return version_docs + def _contexts_for_versions(self, project_name, version_ids): # TODO fix hero version version_context_by_id = {} @@ -394,7 +427,7 @@ def _contexts_for_versions(self, project_name, version_ids): if not project_name and not version_ids: return version_context_by_id, repre_context_by_id - version_docs = list(get_versions(project_name, version_ids)) + version_docs = self._get_version_docs(project_name, version_ids) version_docs_by_id = {} version_docs_by_product_id = collections.defaultdict(list) for version_doc in version_docs: @@ -456,7 +489,7 @@ def _contexts_for_representations(self, project_name, repre_ids): project_name, representation_ids=repre_ids )) version_ids = {r["parent"] for r in repre_docs} - version_docs = get_versions(project_name, version_ids=version_ids) + version_docs = self._get_version_docs(project_name, version_ids) version_docs_by_id = { v["_id"]: v for v in version_docs } @@ -585,8 +618,7 @@ def _trigger_version_loader( project_doc = get_project(project_name) project_doc["code"] = project_doc["data"]["code"] - version_docs = list( - get_versions(project_name, version_ids=version_ids)) + version_docs = self._get_version_docs(project_name, version_ids) product_ids = {v["parent"] for v in version_docs} product_docs = get_subsets(project_name, subset_ids=product_ids) product_docs_by_id = {f["_id"]: f for f in product_docs} @@ -623,7 +655,7 @@ def _trigger_representation_loader( project_name, representation_ids=representation_ids )) version_ids = {r["parent"] for r in repre_docs} - version_docs = get_versions(project_name, version_ids=version_ids) + version_docs = self._get_version_docs(project_name, version_ids) version_docs_by_id = {v["_id"]: v for v in version_docs} product_ids = {v["parent"] for v in version_docs_by_id.values()} product_docs = get_subsets(project_name, subset_ids=product_ids) From 5ddb31ae1e85b9a2940a662c2d6fdc3835a733c8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 9 Oct 2023 10:39:29 +0200 Subject: [PATCH 46/69] move current project to the top --- openpype/tools/ayon_utils/widgets/projects_widget.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/openpype/tools/ayon_utils/widgets/projects_widget.py b/openpype/tools/ayon_utils/widgets/projects_widget.py index 1411deba66e..02ceb2d8f02 100644 --- a/openpype/tools/ayon_utils/widgets/projects_widget.py +++ b/openpype/tools/ayon_utils/widgets/projects_widget.py @@ -204,6 +204,12 @@ def __init__(self, *args, **kwargs): self.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) def lessThan(self, left_index, right_index): + if left_index.data(PROJECT_IS_CURRENT_ROLE): + return True + + if right_index.data(PROJECT_IS_CURRENT_ROLE): + return False + if left_index.data(PROJECT_NAME_ROLE) is None: return True @@ -385,6 +391,10 @@ def get_current_project_name(self): return None return self._projects_combobox.itemData(idx, PROJECT_NAME_ROLE) + def set_current_context_project(self, project_name): + self._projects_model.set_current_context_project(project_name) + self._projects_proxy_model.invalidateFilter() + def _update_select_item_visiblity(self, **kwargs): if not self._select_item_visible: return From cce2a269e871a665b374abb66abc29b945ae3916 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 9 Oct 2023 11:09:59 +0200 Subject: [PATCH 47/69] fix reset --- .../tools/ayon_loader/ui/folders_widget.py | 6 ++ .../tools/ayon_loader/ui/products_widget.py | 5 ++ openpype/tools/ayon_loader/ui/window.py | 63 ++++++++++++++++++- .../ayon_utils/widgets/projects_widget.py | 3 + 4 files changed, 75 insertions(+), 2 deletions(-) diff --git a/openpype/tools/ayon_loader/ui/folders_widget.py b/openpype/tools/ayon_loader/ui/folders_widget.py index e3760c101c5..08e89a6a552 100644 --- a/openpype/tools/ayon_loader/ui/folders_widget.py +++ b/openpype/tools/ayon_loader/ui/folders_widget.py @@ -264,6 +264,8 @@ class LoaderFoldersWidget(QtWidgets.QWidget): the expected selection. Defaults to False. """ + refreshed = QtCore.Signal() + def __init__(self, controller, parent, handle_expected_selection=False): super(LoaderFoldersWidget, self).__init__(parent) @@ -336,6 +338,9 @@ def set_merged_products_selection(self, items): self._folders_model.set_merged_products_selection(items) + def refresh(self): + self._folders_model.refresh() + def _on_project_selection_change(self, event): project_name = event["project_name"] self._set_project_name(project_name) @@ -357,6 +362,7 @@ def _on_model_refresh(self): if self._expected_selection: self._set_expected_selection() self._folders_proxy_model.sort(0) + self.refreshed.emit() def _get_selected_item_ids(self): selection_model = self._folders_view.selectionModel() diff --git a/openpype/tools/ayon_loader/ui/products_widget.py b/openpype/tools/ayon_loader/ui/products_widget.py index 45ea5167607..0bfdd3c1d73 100644 --- a/openpype/tools/ayon_loader/ui/products_widget.py +++ b/openpype/tools/ayon_loader/ui/products_widget.py @@ -76,6 +76,7 @@ def sort(self, column, order=None): class ProductsWidget(QtWidgets.QWidget): + refreshed = QtCore.Signal() merged_products_selection_changed = QtCore.Signal() selection_changed = QtCore.Signal() version_changed = QtCore.Signal() @@ -216,6 +217,9 @@ def get_selected_merged_products(self): def get_selected_version_info(self): return self._selected_versions_info + def refresh(self): + self._refresh_model() + def _fill_version_editor(self): model = self._products_proxy_model index_queue = collections.deque() @@ -241,6 +245,7 @@ def _fill_version_editor(self): def _on_refresh(self): self._fill_version_editor() + self.refreshed.emit() def _on_rows_inserted(self): self._fill_version_editor() diff --git a/openpype/tools/ayon_loader/ui/window.py b/openpype/tools/ayon_loader/ui/window.py index 1e7b0710a90..bff0e0b5a2c 100644 --- a/openpype/tools/ayon_loader/ui/window.py +++ b/openpype/tools/ayon_loader/ui/window.py @@ -81,6 +81,39 @@ def _create_content(self, content_layout): content_layout.addWidget(tb_widget) +class RefreshHandler: + def __init__(self): + self._project_refreshed = False + self._folders_refreshed = False + self._products_refreshed = False + + @property + def project_refreshed(self): + return self._products_refreshed + + @property + def folders_refreshed(self): + return self._folders_refreshed + + @property + def products_refreshed(self): + return self._products_refreshed + + def reset(self): + self._project_refreshed = False + self._folders_refreshed = False + self._products_refreshed = False + + def set_project_refreshed(self): + self._project_refreshed = True + + def set_folders_refreshed(self): + self._folders_refreshed = True + + def set_products_refreshed(self): + self._products_refreshed = True + + class LoaderWindow(QtWidgets.QWidget): def __init__(self, controller=None, parent=None): super(LoaderWindow, self).__init__(parent) @@ -197,6 +230,9 @@ def __init__(self, controller=None, parent=None): show_timer.timeout.connect(self._on_show_timer) + projects_combobox.refreshed.connect(self._on_projects_refresh) + folders_widget.refreshed.connect(self._on_folders_refresh) + products_widget.refreshed.connect(self._on_products_refresh) folders_filter_input.textChanged.connect( self._on_folder_filter_change ) @@ -237,9 +273,13 @@ def __init__(self, controller=None, parent=None): "selection.versions.changed", self._on_versions_selection_changed, ) + controller.register_event_callback( + "controller.reset.started", + self._on_controller_reset_start, + ) controller.register_event_callback( "controller.reset.finished", - self._on_controller_reset, + self._on_controller_reset_finish, ) self._main_splitter = main_splitter @@ -263,6 +303,7 @@ def __init__(self, controller=None, parent=None): self._repre_widget = repre_widget self._controller = controller + self._refresh_handler = RefreshHandler() self._first_show = True self._reset_on_show = True self._show_counter = 0 @@ -337,11 +378,16 @@ def _on_go_to_current_context_click(self): def _on_refresh_click(self): self._controller.reset() - def _on_controller_reset(self): + def _on_controller_reset_start(self): + self._refresh_handler.reset() + + def _on_controller_reset_finish(self): context = self._controller.get_current_context() project_name = context["project_name"] self._go_to_current_btn.setVisible(bool(project_name)) self._projects_combobox.set_current_context_project(project_name) + if not self._refresh_handler.project_refreshed: + self._projects_combobox.refresh() def _on_load_finished(self, event): error_info = event["error_info"] @@ -391,3 +437,16 @@ def _update_thumbnails(self): thumbnail_paths.add(thumbnail_path) thumbnail_paths.discard(None) self._thumbnails_widget.set_current_thumbnail_paths(thumbnail_paths) + + def _on_projects_refresh(self): + self._refresh_handler.set_project_refreshed() + if not self._refresh_handler.folders_refreshed: + self._folders_widget.refresh() + + def _on_folders_refresh(self): + self._refresh_handler.set_folders_refreshed() + if not self._refresh_handler.products_refreshed: + self._products_widget.refresh() + + def _on_products_refresh(self): + self._refresh_handler.set_products_refreshed() diff --git a/openpype/tools/ayon_utils/widgets/projects_widget.py b/openpype/tools/ayon_utils/widgets/projects_widget.py index 02ceb2d8f02..49faecb61b0 100644 --- a/openpype/tools/ayon_utils/widgets/projects_widget.py +++ b/openpype/tools/ayon_utils/widgets/projects_widget.py @@ -293,6 +293,8 @@ def set_standard_filter_enabled(self, enabled): class ProjectsCombobox(QtWidgets.QWidget): + refreshed = QtCore.Signal() + def __init__(self, controller, parent, handle_expected_selection=False): super(ProjectsCombobox, self).__init__(parent) @@ -437,6 +439,7 @@ def _on_model_refresh(self): if self._expected_selection: self._set_expected_selection() self._update_select_item_visiblity() + self.refreshed.emit() def _on_projects_refresh_finished(self, event): if event["sender"] != PROJECTS_MODEL_SENDER: From 62a1cff545ec2fa3bcd5235a8c5c8f2b4d887253 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 9 Oct 2023 13:10:59 +0200 Subject: [PATCH 48/69] change icon for library projects --- openpype/tools/ayon_utils/models/projects.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/openpype/tools/ayon_utils/models/projects.py b/openpype/tools/ayon_utils/models/projects.py index ddf3fdaf83d..585909a7e60 100644 --- a/openpype/tools/ayon_utils/models/projects.py +++ b/openpype/tools/ayon_utils/models/projects.py @@ -34,9 +34,13 @@ def __init__(self, name, active, is_library, icon=None): self.active = active self.is_library = is_library if icon is None: + if is_library: + name = "fa.book" + else: + name = "fa.map" icon = { "type": "awesome-font", - "name": "fa.map", + "name": name, "color": get_default_entity_icon_color(), } self.icon = icon From 5b95f0cea3e8ba2bc8e27a35c6e1ca989e4b0bf6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 9 Oct 2023 13:11:27 +0200 Subject: [PATCH 49/69] added libraries separator to project widget --- .../ayon_utils/widgets/projects_widget.py | 118 ++++++++++++++++-- 1 file changed, 111 insertions(+), 7 deletions(-) diff --git a/openpype/tools/ayon_utils/widgets/projects_widget.py b/openpype/tools/ayon_utils/widgets/projects_widget.py index 49faecb61b0..11bb5de51be 100644 --- a/openpype/tools/ayon_utils/widgets/projects_widget.py +++ b/openpype/tools/ayon_utils/widgets/projects_widget.py @@ -7,6 +7,7 @@ PROJECT_IS_ACTIVE_ROLE = QtCore.Qt.UserRole + 2 PROJECT_IS_LIBRARY_ROLE = QtCore.Qt.UserRole + 3 PROJECT_IS_CURRENT_ROLE = QtCore.Qt.UserRole + 4 +LIBRARY_PROJECT_SEPARATOR_ROLE = QtCore.Qt.UserRole + 5 class ProjectsModel(QtGui.QStandardItemModel): @@ -17,15 +18,19 @@ def __init__(self, controller): self._controller = controller self._project_items = {} + self._has_libraries = False self._empty_item = None self._empty_item_added = False self._select_item = None self._select_item_added = False - self._select_item_visible = None + self._libraries_sep_item = None + self._libraries_sep_item_added = False + self._libraries_sep_item_visible = False + self._current_context_project = None self._selected_project = None @@ -51,6 +56,11 @@ def set_select_item_visible(self, visible): if self._selected_project is None: self._add_select_item() + def set_libraries_separator_visible(self, visible): + if self._libraries_sep_item_visible is visible: + return + self._libraries_sep_item_visible = visible + def set_selected_project(self, project_name): if not self._select_item_visible: return @@ -64,13 +74,21 @@ def set_selected_project(self, project_name): def set_current_context_project(self, project_name): if project_name == self._current_context_project: return - prev_item = self._project_items.get(self._current_context_project) - if prev_item is not None: - prev_item.setData(False, PROJECT_IS_CURRENT_ROLE) + self._unset_current_context_project(self._current_context_project) self._current_context_project = project_name + self._set_current_context_project(project_name) + + def _set_current_context_project(self, project_name): item = self._project_items.get(project_name) - if item is not None: - item.setData(True, PROJECT_IS_CURRENT_ROLE) + if item is None: + return + item.setData(True, PROJECT_IS_CURRENT_ROLE) + + def _unset_current_context_project(self, project_name): + item = self._project_items.get(project_name) + if item is None: + return + item.setData(False, PROJECT_IS_CURRENT_ROLE) def _add_empty_item(self): if self._empty_item_added: @@ -95,6 +113,38 @@ def _get_empty_item(self): self._empty_item = item return self._empty_item + def _get_library_sep_item(self): + if self._libraries_sep_item is not None: + return self._libraries_sep_item + + item = QtGui.QStandardItem() + item.setData("Libraries", QtCore.Qt.DisplayRole) + item.setData(True, LIBRARY_PROJECT_SEPARATOR_ROLE) + item.setFlags(QtCore.Qt.NoItemFlags) + self._libraries_sep_item = item + return item + + def _add_library_sep_item(self): + if ( + not self._libraries_sep_item_visible + or self._libraries_sep_item_added + ): + return + self._libraries_sep_item_added = True + item = self._get_library_sep_item() + root_item = self.invisibleRootItem() + root_item.appendRow(item) + + def _remove_library_sep_item(self): + if ( + not self._libraries_sep_item_added + ): + return + self._libraries_sep_item_added = False + item = self._get_library_sep_item() + root_item = self.invisibleRootItem() + root_item.takeRow(item.row()) + def _add_select_item(self): if self._select_item_added: return @@ -164,10 +214,13 @@ def _fill_items(self, project_items): item = self._project_items.pop(project_name) root_item.takeRow(item.row()) + has_library_project = False new_items = [] for project_item in project_items: project_name = project_item.name item = self._project_items.get(project_name) + if project_item.is_library: + has_library_project = True if item is None: item = QtGui.QStandardItem() item.setEditable(False) @@ -182,16 +235,25 @@ def _fill_items(self, project_items): item.setData(is_current, PROJECT_IS_CURRENT_ROLE) self._project_items[project_name] = item + self._set_current_context_project(self._current_context_project) + + self._has_libraries = has_library_project + if new_items: root_item.appendRows(new_items) if self.has_content(): # Make sure "No projects" item is removed self._remove_empty_item() + if has_library_project: + self._add_library_sep_item() + else: + self._remove_library_sep_item() else: # Keep only "No projects" item self._add_empty_item() self._remove_select_item() + self._remove_library_sep_item() class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel): @@ -200,16 +262,49 @@ def __init__(self, *args, **kwargs): self._filter_inactive = True self._filter_standard = False self._filter_library = False + self._sort_by_type = True # Disable case sensitivity self.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) + def _type_sort(self, l_index, r_index): + if not self._sort_by_type: + return None + + l_is_library = l_index.data(PROJECT_IS_LIBRARY_ROLE) + r_is_library = r_index.data(PROJECT_IS_LIBRARY_ROLE) + # Both hare project items + if l_is_library is not None and r_is_library is not None: + if l_is_library is r_is_library: + return None + if l_is_library: + return False + return True + + if l_index.data(LIBRARY_PROJECT_SEPARATOR_ROLE): + if r_is_library is None: + return False + return r_is_library + + if r_index.data(LIBRARY_PROJECT_SEPARATOR_ROLE): + if l_is_library is None: + return True + return l_is_library + return None + def lessThan(self, left_index, right_index): + # Current project always on top + # - make sure this is always first, before any other sorting + # e.g. type sort would move the item lower if left_index.data(PROJECT_IS_CURRENT_ROLE): return True - if right_index.data(PROJECT_IS_CURRENT_ROLE): return False + # Library separator should be before library projects + result = self._type_sort(left_index, right_index) + if result is not None: + return result + if left_index.data(PROJECT_NAME_ROLE) is None: return True @@ -291,6 +386,12 @@ def set_standard_filter_enabled(self, enabled): self._filter_standard = enabled self.invalidateFilter() + def set_sort_by_type(self, enabled): + if self._sort_by_type is enabled: + return + self._sort_by_type = enabled + self.invalidate() + class ProjectsCombobox(QtWidgets.QWidget): refreshed = QtCore.Signal() @@ -413,6 +514,9 @@ def set_select_item_visible(self, visible): self._projects_model.set_select_item_visible(visible) self._update_select_item_visiblity() + def set_libraries_separator_visible(self, visible): + self._projects_model.set_libraries_separator_visible(visible) + def is_active_filter_enabled(self): return self._projects_proxy_model.is_active_filter_enabled() From 7d2bd3dedcf5b2ec74c05aeb37924c0f67a04584 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 9 Oct 2023 13:11:58 +0200 Subject: [PATCH 50/69] show libraries separator in loader --- openpype/tools/ayon_loader/ui/window.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/tools/ayon_loader/ui/window.py b/openpype/tools/ayon_loader/ui/window.py index bff0e0b5a2c..d413675d4cf 100644 --- a/openpype/tools/ayon_loader/ui/window.py +++ b/openpype/tools/ayon_loader/ui/window.py @@ -143,6 +143,7 @@ def __init__(self, controller=None, parent=None): handle_expected_selection=True ) projects_combobox.set_select_item_visible(True) + projects_combobox.set_libraries_separator_visible(True) projects_combobox.set_standard_filter_enabled( controller.is_standard_projects_filter_enabled() ) From 6a67c116b9049b057085416ec591f32a6f52721b Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 9 Oct 2023 13:20:01 +0200 Subject: [PATCH 51/69] ise single line expression --- openpype/tools/ayon_utils/models/projects.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/openpype/tools/ayon_utils/models/projects.py b/openpype/tools/ayon_utils/models/projects.py index 585909a7e60..4ad53fbbfab 100644 --- a/openpype/tools/ayon_utils/models/projects.py +++ b/openpype/tools/ayon_utils/models/projects.py @@ -34,13 +34,9 @@ def __init__(self, name, active, is_library, icon=None): self.active = active self.is_library = is_library if icon is None: - if is_library: - name = "fa.book" - else: - name = "fa.map" icon = { "type": "awesome-font", - "name": name, + "name": "fa.book" if is_library else "fa.map", "color": get_default_entity_icon_color(), } self.icon = icon From 7b970b31483cad9b8360e6d6e0f571e610155f39 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 9 Oct 2023 13:29:07 +0200 Subject: [PATCH 52/69] library loader tool is loader tool in AYON mode --- openpype/tools/utils/host_tools.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/openpype/tools/utils/host_tools.py b/openpype/tools/utils/host_tools.py index 515865ec0c3..ca239453395 100644 --- a/openpype/tools/utils/host_tools.py +++ b/openpype/tools/utils/host_tools.py @@ -119,7 +119,7 @@ def show_loader(self, parent=None, use_context=None): if use_context is None: use_context = False - if use_context: + if not AYON_SERVER_ENABLED and use_context: context = {"asset": get_current_asset_name()} loader_tool.set_context(context, refresh=True) else: @@ -197,6 +197,9 @@ def show_scene_inventory(self, parent=None): def get_library_loader_tool(self, parent): """Create, cache and return library loader tool window.""" + if AYON_SERVER_ENABLED: + return self.get_loader_tool(parent) + if self._library_loader_tool is None: from openpype.tools.libraryloader import LibraryLoaderWindow @@ -209,6 +212,9 @@ def get_library_loader_tool(self, parent): def show_library_loader(self, parent=None): """Loader tool for loading representations from library project.""" + if AYON_SERVER_ENABLED: + return self.show_loader(parent) + with qt_app_context(): library_loader_tool = self.get_library_loader_tool(parent) library_loader_tool.show() From 538be746af6b5ef8587b83bcf69466e7cb14f919 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 9 Oct 2023 14:41:43 +0200 Subject: [PATCH 53/69] fixes in grouping model --- .../tools/ayon_loader/ui/products_model.py | 28 +++++++++++++++---- openpype/tools/ayon_loader/ui/window.py | 4 +++ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/openpype/tools/ayon_loader/ui/products_model.py b/openpype/tools/ayon_loader/ui/products_model.py index 482fc48f361..fcdc5b61fec 100644 --- a/openpype/tools/ayon_loader/ui/products_model.py +++ b/openpype/tools/ayon_loader/ui/products_model.py @@ -3,6 +3,7 @@ import qtawesome from qtpy import QtGui, QtCore +from openpype.style import get_default_entity_icon_color from openpype.tools.ayon_utils.widgets import get_qt_icon PRODUCTS_MODEL_SENDER_NAME = "qt_products_model" @@ -82,9 +83,10 @@ def __init__(self, controller): # product item objects (they have version information) self._product_items_by_id = {} - self._grouping_enabled = False + self._grouping_enabled = True self._reset_merge_color = False self._color_iterator = self._color_iter() + self._group_icon = None self._last_project_name = None self._last_folder_ids = [] @@ -112,8 +114,6 @@ def set_enable_grouping(self, enable_grouping): return self._grouping_enabled = enable_grouping # Ignore change if groups are not available - if not self._group_items_by_name: - return self.refresh(self._last_project_name, self._last_folder_ids) def flags(self, index): @@ -244,10 +244,21 @@ def _clear(self): self._product_items_by_id = {} self._reset_merge_color = True + def _get_group_icon(self): + if self._group_icon is None: + self._group_icon = qtawesome.icon( + "fa.object-group", + color=get_default_entity_icon_color() + ) + return self._group_icon + def _get_group_model_item(self, group_name): model_item = self._group_items_by_name.get(group_name) if model_item is None: model_item = QtGui.QStandardItem(group_name) + model_item.setData( + self._get_group_icon(), QtCore.Qt.DecorationRole + ) model_item.setData(0, GROUP_TYPE_ROLE) model_item.setEditable(False) model_item.setColumnCount(self.columnCount()) @@ -375,7 +386,10 @@ def refresh(self, project_name, folder_ids): else: path = "/".join(key_parts + [product_name]) merged_paths.add(path) - merged_product_items[path] = product_items + merged_product_items[path] = { + "product_name": product_name, + "product_items": product_items, + } parent_item = None if group_name: @@ -391,12 +405,14 @@ def refresh(self, project_name, folder_ids): item = self._get_product_model_item(product_item) new_items.append(item) - for path, product_items in merged_product_items.items(): + for path, path_info in merged_product_items.items(): + product_items = path_info["product_items"] + product_name = path_info["product_name"] (merged_color_hex, merged_color_qt) = self._get_next_color() merged_color = qtawesome.icon( "fa.circle", color=merged_color_qt) merged_item = self._get_merged_model_item( - path, len(product_items), merged_color_hex) + product_name, len(product_items), merged_color_hex) merged_item.setData(merged_color, QtCore.Qt.DecorationRole) new_items.append(merged_item) diff --git a/openpype/tools/ayon_loader/ui/window.py b/openpype/tools/ayon_loader/ui/window.py index d413675d4cf..80f9c423ce6 100644 --- a/openpype/tools/ayon_loader/ui/window.py +++ b/openpype/tools/ayon_loader/ui/window.py @@ -313,6 +313,10 @@ def __init__(self, controller=None, parent=None): self._selected_folder_ids = set() self._selected_version_ids = set() + self._products_widget.set_enable_grouping( + self._product_group_checkbox.isChecked() + ) + def showEvent(self, event): super(LoaderWindow, self).showEvent(event) From a950741acc03fbb96be6d8dcf7d0d6af4e62d02a Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 9 Oct 2023 14:42:24 +0200 Subject: [PATCH 54/69] implemented grouping logic --- openpype/tools/ayon_loader/abstract.py | 4 ++ openpype/tools/ayon_loader/control.py | 5 +++ openpype/tools/ayon_loader/models/products.py | 35 +++++++++++++++ .../ayon_loader/ui/product_group_dialog.py | 45 +++++++++++++++++++ .../tools/ayon_loader/ui/products_widget.py | 20 +++++---- openpype/tools/ayon_loader/ui/window.py | 34 ++++++++++++++ 6 files changed, 135 insertions(+), 8 deletions(-) create mode 100644 openpype/tools/ayon_loader/ui/product_group_dialog.py diff --git a/openpype/tools/ayon_loader/abstract.py b/openpype/tools/ayon_loader/abstract.py index c472e788e51..52719173922 100644 --- a/openpype/tools/ayon_loader/abstract.py +++ b/openpype/tools/ayon_loader/abstract.py @@ -261,6 +261,10 @@ def get_folder_thumbnail_ids(self, project_name, folder_ids): def get_thumbnail_path(self, project_name, thumbnail_id): pass + @abstractmethod + def change_products_group(self, project_name, product_ids, group_name): + pass + # Load action items @abstractmethod def get_versions_action_items(self, project_name, version_ids): diff --git a/openpype/tools/ayon_loader/control.py b/openpype/tools/ayon_loader/control.py index 81fd29eb0e5..c656b07a07d 100644 --- a/openpype/tools/ayon_loader/control.py +++ b/openpype/tools/ayon_loader/control.py @@ -210,6 +210,11 @@ def get_thumbnail_path(self, project_name, thumbnail_id): project_name, thumbnail_id ) + def change_products_group(self, project_name, product_ids, group_name): + self._products_model.change_products_group( + project_name, product_ids, group_name + ) + def get_versions_action_items(self, project_name, version_ids): return self._loader_actions_model.get_versions_action_items( project_name, version_ids) diff --git a/openpype/tools/ayon_loader/models/products.py b/openpype/tools/ayon_loader/models/products.py index f9b6c2772fd..8337269643c 100644 --- a/openpype/tools/ayon_loader/models/products.py +++ b/openpype/tools/ayon_loader/models/products.py @@ -3,6 +3,7 @@ import arrow import ayon_api +from ayon_api.operations import OperationsSession from openpype.style import get_default_entity_icon_color from openpype.tools.ayon_utils.models import NestedCacheItem @@ -220,6 +221,40 @@ def get_product_ids_by_repre_ids(self, project_name, repre_ids): ) return {v["productId"] for v in versions} + def change_products_group(self, project_name, product_ids, group_name): + if not product_ids: + return + + product_items = self._get_product_items_by_id( + project_name, product_ids + ) + if not product_items: + return + + session = OperationsSession() + folder_ids = set() + for product_item in product_items.values(): + session.update_entity( + project_name, + "product", + product_item.product_id, + {"attrib": {"productGroup": group_name}} + ) + folder_ids.add(product_item.folder_id) + product_item.group_name = group_name + + session.commit() + self._controller.emit_event( + "products.group.changed", + { + "project_name": project_name, + "folder_ids": folder_ids, + "product_ids": product_ids, + "group_name": group_name, + }, + PRODUCTS_MODEL_SENDER + ) + def _get_product_items_by_id(self, project_name, product_ids): product_item_by_id = self._product_item_by_id[project_name] missing_product_ids = set() diff --git a/openpype/tools/ayon_loader/ui/product_group_dialog.py b/openpype/tools/ayon_loader/ui/product_group_dialog.py new file mode 100644 index 00000000000..5737ce58a4b --- /dev/null +++ b/openpype/tools/ayon_loader/ui/product_group_dialog.py @@ -0,0 +1,45 @@ +from qtpy import QtWidgets + +from openpype.tools.utils import PlaceholderLineEdit + + +class ProductGroupDialog(QtWidgets.QDialog): + def __init__(self, controller, parent): + super(ProductGroupDialog, self).__init__(parent) + self.setWindowTitle("Grouping products") + self.setMinimumWidth(250) + self.setModal(True) + + main_label = QtWidgets.QLabel("Group Name", self) + + group_name_input = PlaceholderLineEdit(self) + group_name_input.setPlaceholderText("Remain blank to ungroup..") + + group_btn = QtWidgets.QPushButton("Apply", self) + group_btn.setAutoDefault(True) + group_btn.setDefault(True) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(main_label, 0) + layout.addWidget(group_name_input, 0) + layout.addWidget(group_btn, 0) + + group_btn.clicked.connect(self._on_apply_click) + + self._project_name = None + self._product_ids = set() + + self._controller = controller + self._group_btn = group_btn + self._group_name_input = group_name_input + + def set_product_ids(self, project_name, product_ids): + self._project_name = project_name + self._product_ids = product_ids + + def _on_apply_click(self): + group_name = self._group_name_input.text().strip() or None + self._controller.change_products_group( + self._project_name, self._product_ids, group_name + ) + self.close() diff --git a/openpype/tools/ayon_loader/ui/products_widget.py b/openpype/tools/ayon_loader/ui/products_widget.py index 0bfdd3c1d73..cfc18431a68 100644 --- a/openpype/tools/ayon_loader/ui/products_widget.py +++ b/openpype/tools/ayon_loader/ui/products_widget.py @@ -156,14 +156,10 @@ def __init__(self, controller, parent): "products.refresh.finished", self._on_products_refresh_finished ) - # controller.register_event_callback( - # "controller.refresh.finished", - # self._on_controller_refresh - # ) - # controller.register_event_callback( - # "expected_selection_changed", - # self._on_expected_selection_change - # ) + controller.register_event_callback( + "products.group.changed", + self._on_group_changed + ) self._products_view = products_view self._products_model = products_model @@ -394,3 +390,11 @@ def _update_folders_label_visible(self): def _on_products_refresh_finished(self, event): if event["sender"] != PRODUCTS_MODEL_SENDER_NAME: self._refresh_model() + + def _on_group_changed(self, event): + if event["project_name"] != self._selected_project_name: + return + folder_ids = event["folder_ids"] + if not set(folder_ids).intersection(set(self._selected_folder_ids)): + return + self.refresh() diff --git a/openpype/tools/ayon_loader/ui/window.py b/openpype/tools/ayon_loader/ui/window.py index 80f9c423ce6..d066a95a221 100644 --- a/openpype/tools/ayon_loader/ui/window.py +++ b/openpype/tools/ayon_loader/ui/window.py @@ -16,6 +16,7 @@ from .folders_widget import LoaderFoldersWidget from .products_widget import ProductsWidget from .product_types_widget import ProductTypesView +from .product_group_dialog import ProductGroupDialog from .info_widget import InfoWidget from .repres_widget import RepresentationsWidget @@ -283,6 +284,8 @@ def __init__(self, controller=None, parent=None): self._on_controller_reset_finish, ) + self._group_dialog = ProductGroupDialog(controller, self) + self._main_splitter = main_splitter self._go_to_current_btn = go_to_current_btn @@ -325,6 +328,22 @@ def showEvent(self, event): self._show_timer.start() + def keyPressEvent(self, event): + modifiers = event.modifiers() + ctrl_pressed = QtCore.Qt.ControlModifier & modifiers + + # Grouping products on pressing Ctrl + G + if ( + ctrl_pressed + and event.key() == QtCore.Qt.Key_G + and not event.isAutoRepeat() + ): + self._show_group_dialog() + event.setAccepted(True) + return + + super(LoaderWindow, self).keyPressEvent(event) + def _on_first_show(self): self._first_show = False self.resize(1800, 900) @@ -346,6 +365,21 @@ def _on_show_timer(self): self._reset_on_show = False self._controller.reset() + def _show_group_dialog(self): + project_name = self._projects_combobox.get_current_project_name() + if not project_name: + return + + product_ids = { + i["product_id"] + for i in self._products_widget.get_selected_version_info() + } + if not product_ids: + return + + self._group_dialog.set_product_ids(project_name, product_ids) + self._group_dialog.show() + def _on_folder_filter_change(self, text): self._folders_widget.set_name_filer(text) From adf177c67ec34c755faff4cd5753519238e8c076 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 9 Oct 2023 14:49:02 +0200 Subject: [PATCH 55/69] use loader in tray action --- openpype/modules/avalon_apps/avalon_app.py | 40 +++++++++++++++------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/openpype/modules/avalon_apps/avalon_app.py b/openpype/modules/avalon_apps/avalon_app.py index a0226ecc5ce..57754793c41 100644 --- a/openpype/modules/avalon_apps/avalon_app.py +++ b/openpype/modules/avalon_apps/avalon_app.py @@ -1,5 +1,6 @@ import os +from openpype import AYON_SERVER_ENABLED from openpype.modules import OpenPypeModule, ITrayModule @@ -75,20 +76,11 @@ def tray_exit(self, *_a, **_kw): def show_library_loader(self): if self._library_loader_window is None: - from qtpy import QtCore - from openpype.tools.libraryloader import LibraryLoaderWindow from openpype.pipeline import install_openpype_plugins - - libraryloader = LibraryLoaderWindow( - show_projects=True, - show_libraries=True - ) - # Remove always on top flag for tray - window_flags = libraryloader.windowFlags() - if window_flags | QtCore.Qt.WindowStaysOnTopHint: - window_flags ^= QtCore.Qt.WindowStaysOnTopHint - libraryloader.setWindowFlags(window_flags) - self._library_loader_window = libraryloader + if AYON_SERVER_ENABLED: + self._init_ayon_loader() + else: + self._init_library_loader() install_openpype_plugins() @@ -106,3 +98,25 @@ def webserver_initialization(self, server_manager): if self.tray_initialized: from .rest_api import AvalonRestApiResource self.rest_api_obj = AvalonRestApiResource(self, server_manager) + + def _init_library_loader(self): + from qtpy import QtCore + from openpype.tools.libraryloader import LibraryLoaderWindow + + libraryloader = LibraryLoaderWindow( + show_projects=True, + show_libraries=True + ) + # Remove always on top flag for tray + window_flags = libraryloader.windowFlags() + if window_flags | QtCore.Qt.WindowStaysOnTopHint: + window_flags ^= QtCore.Qt.WindowStaysOnTopHint + libraryloader.setWindowFlags(window_flags) + self._library_loader_window = libraryloader + + def _init_ayon_loader(self): + from openpype.tools.ayon_loader.ui import LoaderWindow + + libraryloader = LoaderWindow() + + self._library_loader_window = libraryloader From 251b95d441e0a8d0898679288b9092a4d1feffa6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 9 Oct 2023 14:55:00 +0200 Subject: [PATCH 56/69] better initial sizes --- openpype/tools/ayon_loader/ui/window.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/openpype/tools/ayon_loader/ui/window.py b/openpype/tools/ayon_loader/ui/window.py index d066a95a221..285a26a4001 100644 --- a/openpype/tools/ayon_loader/ui/window.py +++ b/openpype/tools/ayon_loader/ui/window.py @@ -346,9 +346,22 @@ def keyPressEvent(self, event): def _on_first_show(self): self._first_show = False - self.resize(1800, 900) - self._main_splitter.setSizes([350, 1000, 350]) - self._right_panel_splitter.setSizes([250, 325, 325]) + # width, height = 1800, 900 + width, height = 1500, 750 + + self.resize(width, height) + + mid_width = int(width / 1.8) + sides_width = int((width - mid_width) * 0.5) + self._main_splitter.setSizes( + [sides_width, mid_width, sides_width] + ) + + thumbnail_height = int(height / 3.6) + info_height = int((height - thumbnail_height) * 0.5) + self._right_panel_splitter.setSizes( + [thumbnail_height, info_height, info_height] + ) self.setStyleSheet(load_stylesheet()) center_window(self) self._controller.reset() From db97e3b7bd33ef3dd473a9ad813d47ed4818d542 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 9 Oct 2023 15:03:15 +0200 Subject: [PATCH 57/69] moved 'ActionItem' to abstract --- openpype/tools/ayon_loader/abstract.py | 47 +++++++ openpype/tools/ayon_loader/models/actions.py | 124 +------------------ 2 files changed, 48 insertions(+), 123 deletions(-) diff --git a/openpype/tools/ayon_loader/abstract.py b/openpype/tools/ayon_loader/abstract.py index 52719173922..77d9e7e74d4 100644 --- a/openpype/tools/ayon_loader/abstract.py +++ b/openpype/tools/ayon_loader/abstract.py @@ -177,6 +177,53 @@ def from_data(cls, data): return cls(**data) +class ActionItem: + def __init__( + self, + identifier, + label, + icon, + tooltip, + options, + order, + project_name, + folder_ids, + product_ids, + version_ids, + representation_ids, + ): + self.identifier = identifier + self.label = label + self.icon = icon + self.tooltip = tooltip + self.options = options + self.order = order + self.project_name = project_name + self.folder_ids = folder_ids + self.product_ids = product_ids + self.version_ids = version_ids + self.representation_ids = representation_ids + + def to_data(self): + return { + "identifier": self.identifier, + "label": self.label, + "icon": self.icon, + "tooltip": self.tooltip, + "options": self.options, + "order": self.order, + "project_name": self.project_name, + "folder_ids": self.folder_ids, + "product_ids": self.product_ids, + "version_ids": self.version_ids, + "representation_ids": self.representation_ids, + } + + @classmethod + def from_data(cls, data): + return cls(**data) + + @six.add_metaclass(ABCMeta) class AbstractController: @abstractmethod diff --git a/openpype/tools/ayon_loader/models/actions.py b/openpype/tools/ayon_loader/models/actions.py index 1b781b9f758..4859c01c3f9 100644 --- a/openpype/tools/ayon_loader/models/actions.py +++ b/openpype/tools/ayon_loader/models/actions.py @@ -4,9 +4,6 @@ import copy import collections import uuid -from abc import ABCMeta, abstractmethod - -import six from openpype.client import ( get_project, @@ -27,130 +24,11 @@ IncompatibleLoaderError, ) from openpype.tools.ayon_utils.models import NestedCacheItem +from openpype.tools.ayon_loader.abstract import ActionItem ACTIONS_MODEL_SENDER = "actions.model" -@six.add_metaclass(ABCMeta) -class BaseActionItem(object): - @abstractmethod - def item_type(self): - pass - - @abstractmethod - def to_data(self): - pass - - @classmethod - @abstractmethod - def from_data(cls, data): - pass - - -class ActionItem(BaseActionItem): - def __init__( - self, - identifier, - label, - icon, - tooltip, - options, - order, - project_name, - folder_ids, - product_ids, - version_ids, - representation_ids, - ): - self.identifier = identifier - self.label = label - self.icon = icon - self.tooltip = tooltip - self.options = options - self.order = order - self.project_name = project_name - self.folder_ids = folder_ids - self.product_ids = product_ids - self.version_ids = version_ids - self.representation_ids = representation_ids - - def item_type(self): - return "action" - - def to_data(self): - return { - "item_type": self.item_type(), - "identifier": self.identifier, - "label": self.label, - "icon": self.icon, - "tooltip": self.tooltip, - "options": self.options, - "order": self.order, - "project_name": self.project_name, - "folder_ids": self.folder_ids, - "product_ids": self.product_ids, - "version_ids": self.version_ids, - "representation_ids": self.representation_ids, - } - - @classmethod - def from_data(cls, data): - new_data = copy.deepcopy(data) - new_data.pop("item_type") - return cls(**new_data) - - -# NOTE This is just an idea. Not implemented on front end, -# also hits issues with sorting of items in the UI. -# class SeparatorItem(BaseActionItem): -# def item_type(self): -# return "separator" -# -# def to_data(self): -# return {"item_type": self.item_type()} -# -# @classmethod -# def from_data(cls, data): -# return cls() -# -# -# class MenuItem(BaseActionItem): -# def __init__(self, label, icon, children): -# self.label = label -# self.icon = icon -# self.children = children -# -# def item_type(self): -# return "menu" -# -# def to_data(self): -# return { -# "item_type": self.item_type(), -# "label": self.label, -# "icon": self.icon, -# "children": [child.to_data() for child in self.children] -# } -# -# @classmethod -# def from_data(cls, data): -# new_data = copy.deepcopy(data) -# new_data.pop("item_type") -# children = [] -# for child in data["children"]: -# child_type = child["item_type"] -# if child_type == "separator": -# children.append(SeparatorItem.from_data(child)) -# elif child_type == "menu": -# children.append(MenuItem.from_data(child)) -# elif child_type == "action": -# children.append(ActionItem.from_data(child)) -# else: -# raise ValueError("Invalid child type: {}".format(child_type)) -# -# new_data["children"] = children -# return cls(**new_data) - - class LoaderActionsModel: """Model for loader actions. From bd2b35cdcb40e733f9ce797bf21ce68e42f73c45 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 9 Oct 2023 15:03:46 +0200 Subject: [PATCH 58/69] filter loaders by tool name based on current context project --- openpype/tools/ayon_loader/models/actions.py | 26 +++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/openpype/tools/ayon_loader/models/actions.py b/openpype/tools/ayon_loader/models/actions.py index 4859c01c3f9..6f4d3f0452b 100644 --- a/openpype/tools/ayon_loader/models/actions.py +++ b/openpype/tools/ayon_loader/models/actions.py @@ -27,6 +27,7 @@ from openpype.tools.ayon_loader.abstract import ActionItem ACTIONS_MODEL_SENDER = "actions.model" +NOT_SET = object() class LoaderActionsModel: @@ -52,7 +53,7 @@ class LoaderActionsModel: def __init__(self, controller): self._controller = controller - self._tool_name = "" + self._current_context_project = NOT_SET self._loaders_by_identifier = NestedCacheItem( levels=1, lifetime=self.loaders_cache_lifetime) self._product_loaders = NestedCacheItem( @@ -61,6 +62,7 @@ def __init__(self, controller): levels=1, lifetime=self.loaders_cache_lifetime) def reset(self): + self._current_context_project = NOT_SET self._loaders_by_identifier.reset() self._product_loaders.reset() self._repre_loaders.reset() @@ -138,6 +140,12 @@ def trigger_action_item( ACTIONS_MODEL_SENDER, ) + def _get_current_context_project(self): + if self._current_context_project is NOT_SET: + context = self._controller.get_current_context() + self._current_context_project = context["project_name"] + return self._current_context_project + def _get_loader_label(self, loader, representation=None): """Pull label info from loader class""" label = getattr(loader, "label", None) @@ -165,16 +173,22 @@ def _get_loader_tooltip(self, loader): # Add tooltip and statustip from Loader docstring return inspect.getdoc(loader) - def _filter_loaders_by_tool_name(self, loaders): - if not self._tool_name: - return loaders + def _filter_loaders_by_tool_name(self, project_name, loaders): + # Keep filtering by tool name + # - if current context project name is same as project name we do + # expect the tool is used as OpenPype loader tool, otherwise + # as library loader tool. + if project_name == self._get_current_context_project(): + tool_name = "loader" + else: + tool_name = "library_loader" filtered_loaders = [] for loader in loaders: tool_names = getattr(loader, "tool_names", None) if ( tool_names is None or "*" in tool_names - or self._tool_name in tool_names + or tool_name in tool_names ): filtered_loaders.append(loader) return filtered_loaders @@ -231,7 +245,7 @@ def _get_loaders(self, project_name): # Get all representation->loader combinations available for the # index under the cursor, so we can list the user the options. available_loaders = self._filter_loaders_by_tool_name( - discover_loader_plugins(project_name) + project_name, discover_loader_plugins(project_name) ) repre_loaders = [] From 58d9e84293e7ce4ba3587a9f795a18adff3fd567 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 9 Oct 2023 15:08:00 +0200 Subject: [PATCH 59/69] formatting fixes --- openpype/tools/ayon_loader/models/actions.py | 13 ++++++++----- openpype/tools/ayon_loader/models/products.py | 3 ++- openpype/tools/ayon_loader/ui/folders_widget.py | 5 +---- openpype/tools/ayon_loader/ui/products_model.py | 13 ++++++------- openpype/tools/ayon_loader/ui/window.py | 16 ++++++++++------ openpype/tools/ayon_utils/models/thumbnails.py | 3 ++- 6 files changed, 29 insertions(+), 24 deletions(-) diff --git a/openpype/tools/ayon_loader/models/actions.py b/openpype/tools/ayon_loader/models/actions.py index 6f4d3f0452b..dabd65d4f80 100644 --- a/openpype/tools/ayon_loader/models/actions.py +++ b/openpype/tools/ayon_loader/models/actions.py @@ -580,8 +580,9 @@ def _load_representations_by_loader(self, loader, repre_contexts, options): Args: loader (LoaderPlugin): Loader plugin to use. - repre_contexts (list[dict]): Full info about selected representations, - containing repre, version, subset, asset and project documents. + repre_contexts (list[dict]): Full info about selected + representations, containing repre, version, subset, asset and + project documents. options (dict): Data from options. """ @@ -683,9 +684,11 @@ def _load_products_by_loader(self, loader, version_contexts, options): formatted_traceback = None if not isinstance(exc, LoadError): exc_type, exc_value, exc_traceback = sys.exc_info() - formatted_traceback = "".join(traceback.format_exception( - exc_type, exc_value, exc_traceback - )) + formatted_traceback = "".join( + traceback.format_exception( + exc_type, exc_value, exc_traceback + ) + ) error_info.append(( str(exc), diff --git a/openpype/tools/ayon_loader/models/products.py b/openpype/tools/ayon_loader/models/products.py index 8337269643c..8579ec56234 100644 --- a/openpype/tools/ayon_loader/models/products.py +++ b/openpype/tools/ayon_loader/models/products.py @@ -118,7 +118,8 @@ def product_type_item_from_data(product_type_data): class ProductsModel: - lifetime = 60 # A minutes + lifetime = 60 # In seconds (minute by default) + def __init__(self, controller): self._controller = controller diff --git a/openpype/tools/ayon_loader/ui/folders_widget.py b/openpype/tools/ayon_loader/ui/folders_widget.py index 08e89a6a552..b9114585466 100644 --- a/openpype/tools/ayon_loader/ui/folders_widget.py +++ b/openpype/tools/ayon_loader/ui/folders_widget.py @@ -5,10 +5,7 @@ RecursiveSortFilterProxyModel, DeselectableTreeView, ) -from openpype.style import ( - get_objected_colors, - get_default_tools_icon_color, -) +from openpype.style import get_objected_colors from openpype.tools.ayon_utils.widgets import ( FoldersModel, diff --git a/openpype/tools/ayon_loader/ui/products_model.py b/openpype/tools/ayon_loader/ui/products_model.py index fcdc5b61fec..741f15766b3 100644 --- a/openpype/tools/ayon_loader/ui/products_model.py +++ b/openpype/tools/ayon_loader/ui/products_model.py @@ -386,10 +386,10 @@ def refresh(self, project_name, folder_ids): else: path = "/".join(key_parts + [product_name]) merged_paths.add(path) - merged_product_items[path] = { - "product_name": product_name, - "product_items": product_items, - } + merged_product_items[path] = ( + product_name, + product_items, + ) parent_item = None if group_name: @@ -405,9 +405,8 @@ def refresh(self, project_name, folder_ids): item = self._get_product_model_item(product_item) new_items.append(item) - for path, path_info in merged_product_items.items(): - product_items = path_info["product_items"] - product_name = path_info["product_name"] + for path_info in merged_product_items.values(): + product_name, product_items = path_info (merged_color_hex, merged_color_qt) = self._get_next_color() merged_color = qtawesome.icon( "fa.circle", color=merged_color_qt) diff --git a/openpype/tools/ayon_loader/ui/window.py b/openpype/tools/ayon_loader/ui/window.py index 285a26a4001..b35de73c457 100644 --- a/openpype/tools/ayon_loader/ui/window.py +++ b/openpype/tools/ayon_loader/ui/window.py @@ -464,15 +464,19 @@ def _update_thumbnails(self): project_name = self._selected_project_name thumbnail_ids = set() if self._selected_version_ids: - thumbnail_id_by_entity_id = self._controller.get_version_thumbnail_ids( - project_name, - self._selected_version_ids + thumbnail_id_by_entity_id = ( + self._controller.get_version_thumbnail_ids( + project_name, + self._selected_version_ids + ) ) thumbnail_ids = set(thumbnail_id_by_entity_id.values()) elif self._selected_folder_ids: - thumbnail_id_by_entity_id = self._controller.get_folder_thumbnail_ids( - project_name, - self._selected_folder_ids + thumbnail_id_by_entity_id = ( + self._controller.get_folder_thumbnail_ids( + project_name, + self._selected_folder_ids + ) ) thumbnail_ids = set(thumbnail_id_by_entity_id.values()) diff --git a/openpype/tools/ayon_utils/models/thumbnails.py b/openpype/tools/ayon_utils/models/thumbnails.py index f19883e352c..40892338df5 100644 --- a/openpype/tools/ayon_utils/models/thumbnails.py +++ b/openpype/tools/ayon_utils/models/thumbnails.py @@ -8,7 +8,8 @@ class ThumbnailsModel: - entity_cache_lifetime = 240 + entity_cache_lifetime = 240 # In seconds + def __init__(self): self._thumbnail_cache = AYONThumbnailCache() self._paths_cache = collections.defaultdict(dict) From 56d0bde44acd63b2ff9925ff4b47e94452791777 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 9 Oct 2023 15:22:34 +0200 Subject: [PATCH 60/69] separate abstract classes into frontend and backend abstractions --- openpype/tools/ayon_loader/abstract.py | 89 +++++++++++++------------- openpype/tools/ayon_loader/control.py | 7 +- 2 files changed, 47 insertions(+), 49 deletions(-) diff --git a/openpype/tools/ayon_loader/abstract.py b/openpype/tools/ayon_loader/abstract.py index 77d9e7e74d4..ed5e35e87c0 100644 --- a/openpype/tools/ayon_loader/abstract.py +++ b/openpype/tools/ayon_loader/abstract.py @@ -225,17 +225,53 @@ def from_data(cls, data): @six.add_metaclass(ABCMeta) -class AbstractController: +class BaseLoaderController(object): + @abstractmethod + def get_current_context(self): + """Current context is a context of the current scene. + + Example output: + { + "project_name": "MyProject", + "folder_id": "0011223344-5566778-99", + "task_name": "Compositing", + } + + Returns: + dict[str, Union[str, None]]: Context data. + """ + + pass + + @abstractmethod + def reset(self): + pass + + # Model wrappers + @abstractmethod + def get_folder_items(self, project_name, sender=None): + pass + + +class BackendLoaderController(BaseLoaderController): @abstractmethod def emit_event(self, topic, data=None, source=None): pass @abstractmethod - def register_event_callback(self, topic, callback): + def get_loaded_product_ids(self): + """Return set of loaded product ids. + + Returns: + set[str]: Set of loaded product ids. + """ + pass + +class FrontendLoaderController(BaseLoaderController): @abstractmethod - def reset(self): + def register_event_callback(self, topic, callback): pass # Expected selection helpers @@ -260,10 +296,6 @@ def expected_folder_selected(self, folder_id): def get_project_items(self): pass - @abstractmethod - def get_folder_items(self, project_name, sender=None): - pass - @abstractmethod def get_product_items(self, project_name, folder_ids, sender=None): pass @@ -292,10 +324,6 @@ def get_representation_items( ): pass - @abstractmethod - def get_folder_entity(self, project_name, folder_id): - pass - @abstractmethod def get_version_thumbnail_ids(self, project_name, version_ids): pass @@ -340,27 +368,27 @@ def get_selected_project_name(self): pass @abstractmethod - def set_selected_project(self, project_name): + def get_selected_folder_ids(self): pass @abstractmethod - def get_selected_folder_ids(self): + def get_selected_version_ids(self): pass @abstractmethod - def set_selected_folders(self, folder_ids): + def get_selected_representation_ids(self): pass @abstractmethod - def get_selected_version_ids(self): + def set_selected_project(self, project_name): pass @abstractmethod - def set_selected_versions(self, version_ids): + def set_selected_folders(self, folder_ids): pass @abstractmethod - def get_selected_representation_ids(self): + def set_selected_versions(self, version_ids): pass @abstractmethod @@ -371,23 +399,6 @@ def set_selected_representations(self, repre_ids): def fill_root_in_source(self, source): pass - @abstractmethod - def get_current_context(self): - """Current context is a context of the current scene. - - Example output: - { - "project_name": "MyProject", - "folder_id": "0011223344-5566778-99", - "task_name": "Compositing", - } - - Returns: - dict[str, Union[str, None]]: Context data. - """ - - pass - @abstractmethod def is_loaded_products_supported(self): """Is capable to get information about loaded products. @@ -401,13 +412,3 @@ def is_loaded_products_supported(self): @abstractmethod def is_standard_projects_filter_enabled(self): pass - - @abstractmethod - def get_loaded_product_ids(self): - """Return set of loaded product ids. - - Returns: - set[str]: Set of loaded product ids. - """ - - pass diff --git a/openpype/tools/ayon_loader/control.py b/openpype/tools/ayon_loader/control.py index c656b07a07d..2b779f5c2e4 100644 --- a/openpype/tools/ayon_loader/control.py +++ b/openpype/tools/ayon_loader/control.py @@ -13,7 +13,7 @@ ThumbnailsModel, ) -from .abstract import AbstractController +from .abstract import BackendLoaderController, FrontendLoaderController from .models import SelectionModel, ProductsModel, LoaderActionsModel @@ -82,7 +82,7 @@ def expected_folder_selected(self, folder_id): return True -class LoaderController(AbstractController): +class LoaderController(BackendLoaderController, FrontendLoaderController): """ Args: @@ -194,9 +194,6 @@ def get_representation_items( project_name, version_ids, sender ) - def get_folder_entity(self, project_name, folder_id): - self._hierarchy_model.get_folder_entity(project_name, folder_id) - def get_folder_thumbnail_ids(self, project_name, folder_ids): return self._thumbnails_model.get_folder_thumbnail_ids( project_name, folder_ids) From 10e3dd785c3154bce7db9d540e6262376b686177 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 9 Oct 2023 18:10:46 +0200 Subject: [PATCH 61/69] added docstrings to abstractions --- openpype/tools/ayon_loader/abstract.py | 461 +++++++++++++++++++++++-- 1 file changed, 427 insertions(+), 34 deletions(-) diff --git a/openpype/tools/ayon_loader/abstract.py b/openpype/tools/ayon_loader/abstract.py index ed5e35e87c0..828e2bfb80d 100644 --- a/openpype/tools/ayon_loader/abstract.py +++ b/openpype/tools/ayon_loader/abstract.py @@ -3,6 +3,14 @@ class ProductTypeItem: + """Item representing product type. + + Args: + name (str): Product type name. + icon (dict[str, Any]): Product type icon definition. + checked (bool): Is product type checked for filtering. + """ + def __init__(self, name, icon, checked): self.name = name self.icon = icon @@ -21,6 +29,21 @@ def from_data(cls, data): class ProductItem: + """Product item with it versions. + + Args: + product_id (str): Product id. + product_type (str): Product type. + product_name (str): Product name. + product_icon (dict[str, Any]): Product icon definition. + product_type_icon (dict[str, Any]): Product type icon definition. + product_in_scene (bool): Is product in scene (only when used in DCC). + group_name (str): Group name. + folder_id (str): Folder id. + folder_label (str): Folder label. + version_items (dict[str, VersionItem]): Version items by id. + """ + def __init__( self, product_id, @@ -73,6 +96,27 @@ def from_data(cls, data): class VersionItem: + """Version item. + + Object have implemented comparison operators to be sortable. + + Args: + version_id (str): Version id. + version (int): Version. Can be negative when is hero version. + is_hero (bool): Is hero version. + product_id (str): Product id. + thumbnail_id (Union[str, None]): Thumbnail id. + published_time (Union[str, None]): Published time in format + '%Y%m%dT%H%M%SZ'. + author (Union[str, None]): Author. + frame_range (Union[str, None]): Frame range. + duration (Union[int, None]): Duration. + handles (Union[str, None]): Handles. + step (Union[int, None]): Step. + comment (Union[str, None]): Comment. + source (Union[str, None]): Source. + """ + def __init__( self, version_id, @@ -149,6 +193,16 @@ def from_data(cls, data): class RepreItem: + """Representation item. + + Args: + representation_id (str): Representation id. + representation_name (str): Representation name. + representation_icon (dict[str, Any]): Representation icon definition. + product_name (str): Product name. + folder_label (str): Folder label. + """ + def __init__( self, representation_id, @@ -178,6 +232,26 @@ def from_data(cls, data): class ActionItem: + """Action item that can be triggered. + + Action item is defined for a specific context. To trigger the action + use 'identifier' and context, it necessary also use 'options'. + + Args: + identifier (str): Action identifier. + label (str): Action label. + icon (dict[str, Any]): Action icon definition. + tooltip (str): Action tooltip. + options (Union[list[AbstractAttrDef], list[qargparse.QArgument]]): + Action options. Note: 'qargparse' is considered as deprecated. + order (int): Action order. + project_name (str): Project name. + folder_ids (list[str]): Folder ids. + product_ids (list[str]): Product ids. + version_ids (list[str]): Version ids. + representation_ids (list[str]): Representation ids. + """ + def __init__( self, identifier, @@ -225,7 +299,12 @@ def from_data(cls, data): @six.add_metaclass(ABCMeta) -class BaseLoaderController(object): +class _BaseLoaderController(object): + """Base loader controller abstraction. + + Abstract base class that is required for both frontend and backed. + """ + @abstractmethod def get_current_context(self): """Current context is a context of the current scene. @@ -245,17 +324,70 @@ def get_current_context(self): @abstractmethod def reset(self): + """Reset all cached data to reload everything.""" + pass # Model wrappers @abstractmethod def get_folder_items(self, project_name, sender=None): + """Folder items for a project. + + Args: + project_name (str): Project name. + sender (Optional[str]): Sender who requested the name. + + Returns: + list[FolderItem]: Folder items for the project. + """ + + pass + + # Expected selection helpers + @abstractmethod + def get_expected_selection_data(self): + """Full expected selection information. + + Expected selection is a selection that may not be yet selected in UI + e.g. because of refreshing, this data tell the UI what should be + selected when they finish their refresh. + + Returns: + dict[str, Any]: Expected selection data. + """ + + pass + + @abstractmethod + def set_expected_selection(self, project_name, folder_id): + """Set expected selection. + + Args: + project_name (str): Name of project to be selected. + folder_id (str): Id of folder to be selected. + """ + pass -class BackendLoaderController(BaseLoaderController): +class BackendLoaderController(_BaseLoaderController): + """Backend loader controller abstraction. + + What backend logic requires from a controller for proper logic. + """ + @abstractmethod def emit_event(self, topic, data=None, source=None): + """Emit event with a certain topic, data and source. + + The event should be sent to both frontend and backend. + + Args: + topic (str): Event topic name. + data (Optional[dict[str, Any]]): Event data. + source (Optional[str]): Event source. + """ + pass @abstractmethod @@ -269,40 +401,88 @@ def get_loaded_product_ids(self): pass -class FrontendLoaderController(BaseLoaderController): +class FrontendLoaderController(_BaseLoaderController): @abstractmethod def register_event_callback(self, topic, callback): - pass + """Register callback for an event topic. - # Expected selection helpers - @abstractmethod - def get_expected_selection_data(self): - pass + Args: + topic (str): Event topic name. + callback (func): Callback triggered when the event is emitted. + """ - @abstractmethod - def set_expected_selection(self, project_name, folder_id): pass + # Expected selection helpers @abstractmethod def expected_project_selected(self, project_name): + """Expected project was selected in frontend. + + Args: + project_name (str): Project name. + """ + pass @abstractmethod def expected_folder_selected(self, folder_id): + """Expected folder was selected in frontend. + + Args: + folder_id (str): Folder id. + """ + pass # Model wrapper calls @abstractmethod - def get_project_items(self): + def get_project_items(self, sender=None): + """Items for all projects available on server. + + Triggers event topics "projects.refresh.started" and + "projects.refresh.finished" with data: + { + "sender": sender + } + + Notes: + Filtering of projects is done in UI. + + Args: + sender (Optional[str]): Sender who requested the items. + + Returns: + list[ProjectItem]: List of project items. + """ + pass @abstractmethod def get_product_items(self, project_name, folder_ids, sender=None): + """Product items for folder ids. + + Triggers event topics "products.refresh.started" and + "products.refresh.finished" with data: + { + "project_name": project_name, + "folder_ids": folder_ids, + "sender": sender + } + + Args: + project_name (str): Project name. + folder_ids (Iterable[str]): Folder ids. + sender (Optional[str]): Sender who requested the items. + + Returns: + list[ProductItem]: List of product items. + """ + pass @abstractmethod def get_product_item(self, project_name, product_id): - """ + """Receive single product item. Args: project_name (str): Project name. @@ -316,89 +496,292 @@ def get_product_item(self, project_name, product_id): @abstractmethod def get_product_type_items(self, project_name): + """Product type items for a project. + + Product types have defined if are checked for filtering or not. + + Returns: + list[ProductTypeItem]: List of product type items for a project. + """ + pass @abstractmethod def get_representation_items( self, project_name, version_ids, sender=None ): + """Representation items for version ids. + + Triggers event topics "model.representations.refresh.started" and + "model.representations.refresh.finished" with data: + { + "project_name": project_name, + "version_ids": version_ids, + "sender": sender + } + + Args: + project_name (str): Project name. + version_ids (Iterable[str]): Version ids. + sender (Optional[str]): Sender who requested the items. + + Returns: + list[RepreItem]: List of representation items. + """ + pass @abstractmethod def get_version_thumbnail_ids(self, project_name, version_ids): + """Get thumbnail ids for version ids. + + Args: + project_name (str): Project name. + version_ids (Iterable[str]): Version ids. + + Returns: + dict[str, Union[str, Any]]: Thumbnail id by version id. + """ + pass @abstractmethod def get_folder_thumbnail_ids(self, project_name, folder_ids): + """Get thumbnail ids for folder ids. + + Args: + project_name (str): Project name. + folder_ids (Iterable[str]): Folder ids. + + Returns: + dict[str, Union[str, Any]]: Thumbnail id by folder id. + """ + pass @abstractmethod def get_thumbnail_path(self, project_name, thumbnail_id): - pass + """Get thumbnail path for thumbnail id. - @abstractmethod - def change_products_group(self, project_name, product_ids, group_name): - pass + This method should get a path to a thumbnail based on thumbnail id. + Which probably means to download the thumbnail from server and store + it locally. - # Load action items - @abstractmethod - def get_versions_action_items(self, project_name, version_ids): - pass + Args: + project_name (str): Project name. + thumbnail_id (str): Thumbnail id. - @abstractmethod - def get_representations_action_items( - self, project_name, representation_ids - ): - pass + Returns: + Union[str, None]: Thumbnail path or None if not found. + """ - @abstractmethod - def trigger_action_item( - self, - identifier, - options, - project_name, - version_ids, - representation_ids - ): pass # Selection model wrapper calls @abstractmethod def get_selected_project_name(self): + """Get selected project name. + + The information is based on last selection from UI. + + Returns: + Union[str, None]: Selected project name. + """ + pass @abstractmethod def get_selected_folder_ids(self): + """Get selected folder ids. + + The information is based on last selection from UI. + + Returns: + list[str]: Selected folder ids. + """ + pass @abstractmethod def get_selected_version_ids(self): + """Get selected version ids. + + The information is based on last selection from UI. + + Returns: + list[str]: Selected version ids. + """ + pass @abstractmethod def get_selected_representation_ids(self): + """Get selected representation ids. + + The information is based on last selection from UI. + + Returns: + list[str]: Selected representation ids. + """ + pass @abstractmethod def set_selected_project(self, project_name): + """Set selected project. + + Project selection changed in UI. Method triggers event with topic + "selection.project.changed" with data: + { + "project_name": self._project_name + } + + Args: + project_name (Union[str, None]): Selected project name. + """ + pass @abstractmethod def set_selected_folders(self, folder_ids): + """Set selected folders. + + Folder selection changed in UI. Method triggers event with topic + "selection.folders.changed" with data: + { + "project_name": project_name, + "folder_ids": folder_ids + } + + Args: + folder_ids (Iterable[str]): Selected folder ids. + """ + pass @abstractmethod def set_selected_versions(self, version_ids): + """Set selected versions. + + Version selection changed in UI. Method triggers event with topic + "selection.versions.changed" with data: + { + "project_name": project_name, + "folder_ids": folder_ids, + "version_ids": version_ids + } + + Args: + version_ids (Iterable[str]): Selected version ids. + """ + pass @abstractmethod def set_selected_representations(self, repre_ids): + """Set selected representations. + + Representation selection changed in UI. Method triggers event with + topic "selection.representations.changed" with data: + { + "project_name": project_name, + "folder_ids": folder_ids, + "version_ids": version_ids, + "representation_ids": representation_ids + } + + Args: + repre_ids (Iterable[str]): Selected representation ids. + """ + + pass + + # Load action items + @abstractmethod + def get_versions_action_items(self, project_name, version_ids): + """Action items for versions selection. + + Args: + project_name (str): Project name. + version_ids (Iterable[str]): Version ids. + + Returns: + list[ActionItem]: List of action items. + """ + + pass + + @abstractmethod + def get_representations_action_items( + self, project_name, representation_ids + ): + """Action items for representations selection. + + Args: + project_name (str): Project name. + representation_ids (Iterable[str]): Representation ids. + + Returns: + list[ActionItem]: List of action items. + """ + + pass + + @abstractmethod + def trigger_action_item( + self, + identifier, + options, + project_name, + version_ids, + representation_ids + ): + """Trigger action item. + + Args: + identifier (str): Action identifier. + options (dict[str, Any]): Action option values from UI. + project_name (str): Project name. + version_ids (Iterable[str]): Version ids. + representation_ids (Iterable[str]): Representation ids. + """ + + pass + + @abstractmethod + def change_products_group(self, project_name, product_ids, group_name): + """Change group of products. + + Triggers event "products.group.changed" with data: + { + "project_name": project_name, + "folder_ids": folder_ids, + "product_ids": product_ids, + "group_name": group_name, + } + + Args: + project_name (str): Project name. + product_ids (Iterable[str]): Product ids. + group_name (str): New group name. + """ + pass @abstractmethod def fill_root_in_source(self, source): + """Fill root in source path. + + Args: + source (Union[str, None]): Source of a published version. Usually + rootless workfile path. + """ + pass + # NOTE: Methods 'is_loaded_products_supported' and + # 'is_standard_projects_filter_enabled' are both based on being in host + # or not. Maybe we could implement only single method 'is_in_host'? @abstractmethod def is_loaded_products_supported(self): """Is capable to get information about loaded products. @@ -411,4 +794,14 @@ def is_loaded_products_supported(self): @abstractmethod def is_standard_projects_filter_enabled(self): + """Is standard projects filter enabled. + + This is used for filtering out when loader tool is used in a host. In + that case only current project and library projects should be shown. + + Returns: + bool: Frontend should filter out non-library projects, except + current context project. + """ + pass From a337763c2cab10118bf71a26f06513983c7524c3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 9 Oct 2023 18:58:50 +0200 Subject: [PATCH 62/69] implemented 'to_data' and 'from_data' for action item options --- openpype/tools/ayon_loader/abstract.py | 29 +++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/openpype/tools/ayon_loader/abstract.py b/openpype/tools/ayon_loader/abstract.py index 828e2bfb80d..1b09a32d67d 100644 --- a/openpype/tools/ayon_loader/abstract.py +++ b/openpype/tools/ayon_loader/abstract.py @@ -1,6 +1,12 @@ from abc import ABCMeta, abstractmethod import six +from openpype.lib.attribute_definitions import ( + AbstractAttrDef, + serialize_attr_defs, + deserialize_attr_defs, +) + class ProductTypeItem: """Item representing product type. @@ -278,13 +284,30 @@ def __init__( self.version_ids = version_ids self.representation_ids = representation_ids + def _options_to_data(self): + options = self.options + if not options: + return options + if isinstance(options[0], AbstractAttrDef): + return serialize_attr_defs(options) + # NOTE: Data conversion is not used by default in loader tool. But for + # future development of detached UI tools it would be better to be + # prepared for it. + raise NotImplementedError( + "{}.to_data is not implemented. Use Attribute definitions" + " from 'openpype.lib' instead of 'qargparse'.".format( + self.__class__.__name__ + ) + ) + def to_data(self): + options = self._options_to_data() return { "identifier": self.identifier, "label": self.label, "icon": self.icon, "tooltip": self.tooltip, - "options": self.options, + "options": options, "order": self.order, "project_name": self.project_name, "folder_ids": self.folder_ids, @@ -295,6 +318,10 @@ def to_data(self): @classmethod def from_data(cls, data): + options = data["options"] + if options: + options = deserialize_attr_defs(options) + data["options"] = options return cls(**data) From 8f464c6978cd85db0beeac8ea9e7e3f798577b99 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 9 Oct 2023 18:59:10 +0200 Subject: [PATCH 63/69] added more docstrings --- openpype/tools/ayon_loader/abstract.py | 19 +- openpype/tools/ayon_loader/models/actions.py | 211 ++++++++++++++++-- openpype/tools/ayon_loader/models/products.py | 128 ++++++++--- 3 files changed, 308 insertions(+), 50 deletions(-) diff --git a/openpype/tools/ayon_loader/abstract.py b/openpype/tools/ayon_loader/abstract.py index 1b09a32d67d..45042395d9f 100644 --- a/openpype/tools/ayon_loader/abstract.py +++ b/openpype/tools/ayon_loader/abstract.py @@ -351,7 +351,11 @@ def get_current_context(self): @abstractmethod def reset(self): - """Reset all cached data to reload everything.""" + """Reset all cached data to reload everything. + + Triggers events "controller.reset.started" and + "controller.reset.finished". + """ pass @@ -765,6 +769,19 @@ def trigger_action_item( ): """Trigger action item. + Triggers event "load.started" with data: + { + "identifier": identifier, + "id": , + } + + And triggers "load.finished" with data: + { + "identifier": identifier, + "id": , + "error_info": [...], + } + Args: identifier (str): Action identifier. options (dict[str, Any]): Action option values from UI. diff --git a/openpype/tools/ayon_loader/models/actions.py b/openpype/tools/ayon_loader/models/actions.py index dabd65d4f80..3313ad5fde4 100644 --- a/openpype/tools/ayon_loader/models/actions.py +++ b/openpype/tools/ayon_loader/models/actions.py @@ -38,10 +38,10 @@ class LoaderActionsModel: which are expecting mongo documents. TODOs: + Deprecate 'qargparse' usage in loaders and implement conversion + of 'ActionItem' to data (and 'from_data'). Use controller to get entities (documents) -> possible only when loaders are able to handle AYON vs. OpenPype logic. - Cache loaders for time period and reset them on controller refresh. - Also cache them per project. Add missing site sync logic, and if possible remove it from loaders. Implement loader actions to replace load plugins. Ask loader actions to return action items instead of guessing them. @@ -62,12 +62,24 @@ def __init__(self, controller): levels=1, lifetime=self.loaders_cache_lifetime) def reset(self): + """Reset the model with all cached items.""" + self._current_context_project = NOT_SET self._loaders_by_identifier.reset() self._product_loaders.reset() self._repre_loaders.reset() def get_versions_action_items(self, project_name, version_ids): + """Get action items for given version ids. + + Args: + project_name (str): Project name. + version_ids (Iterable[str]): Version ids. + + Returns: + list[ActionItem]: List of action items. + """ + ( version_context_by_id, repre_context_by_id @@ -84,6 +96,16 @@ def get_versions_action_items(self, project_name, version_ids): def get_representations_action_items( self, project_name, representation_ids ): + """Get action items for given representation ids. + + Args: + project_name (str): Project name. + representation_ids (Iterable[str]): Representation ids. + + Returns: + list[ActionItem]: List of action items. + """ + ( product_context_by_id, repre_context_by_id @@ -105,6 +127,22 @@ def trigger_action_item( version_ids, representation_ids ): + """Trigger action by identifier. + + Triggers the action by identifier for given contexts. + + Triggers events "load.started" and "load.finished". Finished event + also contains "error_info" key with error information if any + happened. + + Args: + identifier (str): Loader identifier. + options (dict[str, Any]): Loader option values. + project_name (str): Project name. + version_ids (Iterable[str]): Version ids. + representation_ids (Iterable[str]): Representation ids. + """ + event_data = { "identifier": identifier, "id": uuid.uuid4().hex, @@ -141,13 +179,30 @@ def trigger_action_item( ) def _get_current_context_project(self): + """Get current context project name. + + The value is based on controller (host) and cached. + + Returns: + Union[str, None]: Current context project. + """ + if self._current_context_project is NOT_SET: context = self._controller.get_current_context() self._current_context_project = context["project_name"] return self._current_context_project - def _get_loader_label(self, loader, representation=None): - """Pull label info from loader class""" + def _get_action_label(self, loader, representation=None): + """Pull label info from loader class. + + Args: + loader (LoaderPlugin): Plugin class. + representation (Optional[dict[str, Any]]): Representation data. + + Returns: + str: Action label. + """ + label = getattr(loader, "label", None) if label is None: label = loader.__name__ @@ -156,8 +211,17 @@ def _get_loader_label(self, loader, representation=None): label = "{} ({})".format(label, representation["name"]) return label - def _get_loader_icon(self, loader): - """Pull icon info from loader class""" + def _get_action_icon(self, loader): + """Pull icon info from loader class. + + Args: + loader (LoaderPlugin): Plugin class. + + Returns: + Union[dict[str, Any], None]: Icon definition based on + loader plugin. + """ + # Support font-awesome icons using the `.icon` and `.color` # attributes on plug-ins. icon = getattr(loader, "icon", None) @@ -169,11 +233,34 @@ def _get_loader_icon(self, loader): } return icon - def _get_loader_tooltip(self, loader): + def _get_action_tooltip(self, loader): + """Pull tooltip info from loader class. + + Args: + loader (LoaderPlugin): Plugin class. + + Returns: + str: Action tooltip. + """ + # Add tooltip and statustip from Loader docstring return inspect.getdoc(loader) def _filter_loaders_by_tool_name(self, project_name, loaders): + """Filter loaders by tool name. + + Tool names are based on OpenPype tools loader tool and library + loader tool. The new tool merged both into one tool and the difference + is based only on current project name. + + Args: + project_name (str): Project name. + loaders (list[LoaderPlugin]): List of loader plugins. + + Returns: + list[LoaderPlugin]: Filtered list of loader plugins. + """ + # Keep filtering by tool name # - if current context project name is same as project name we do # expect the tool is used as OpenPype loader tool, otherwise @@ -204,14 +291,14 @@ def _create_loader_action_item( representation_ids=None, repre_name=None, ): - label = self._get_loader_label(loader) + label = self._get_action_label(loader) if repre_name: label = "{} ({})".format(label, repre_name) return ActionItem( get_loader_identifier(loader), label=label, - icon=self._get_loader_icon(loader), - tooltip=self._get_loader_tooltip(loader), + icon=self._get_action_icon(loader), + tooltip=self._get_action_tooltip(loader), options=loader.get_options(contexts), order=loader.order, project_name=project_name, @@ -222,18 +309,16 @@ def _create_loader_action_item( ) def _get_loaders(self, project_name): - """ - - TODOs: - Cache loaders for time period and reset them on controller - refresh. - Cache them per project name. Right now they are collected per - project, but not cached per project. + """Loaders with loaded settings for a project. Questions: - Project name is required because of settings. Should be actually + Project name is required because of settings. Should we actually pass in current project name instead of project name where we want to show loaders for? + + Returns: + tuple[list[SubsetLoaderPlugin], list[LoaderPlugin]]: Discovered + loader plugins. """ loaders_by_identifier_c = self._loaders_by_identifier[project_name] @@ -268,14 +353,18 @@ def _get_loaders(self, project_name): return product_loaders, repre_loaders def _get_loader_by_identifier(self, project_name, identifier): - loaders_by_identifier_c = self._loaders_by_identifier[project_name] - if not loaders_by_identifier_c.is_valid: + if not self._loaders_by_identifier[project_name].is_valid: self._get_loaders(project_name) + loaders_by_identifier_c = self._loaders_by_identifier[project_name] loaders_by_identifier = loaders_by_identifier_c.get_data() return loaders_by_identifier.get(identifier) def _actions_sorter(self, action_item): - """Sort the Loaders by their order and then their name""" + """Sort the Loaders by their order and then their name. + + Returns: + tuple[int, str]: Sort keys. + """ return action_item.order, action_item.label @@ -313,6 +402,24 @@ def _get_version_docs(self, project_name, version_ids): return version_docs def _contexts_for_versions(self, project_name, version_ids): + """Get contexts for given version ids. + + Prepare version contexts for 'SubsetLoaderPlugin' and representation + contexts for 'LoaderPlugin' for all children representations of + given versions. + + This method is very similar to '_contexts_for_representations' but the + queries of documents are called in a different order. + + Args: + project_name (str): Project name. + version_ids (Iterable[str]): Version ids. + + Returns: + tuple[list[dict[str, Any]], list[dict[str, Any]]]: Version and + representation contexts. + """ + # TODO fix hero version version_context_by_id = {} repre_context_by_id = {} @@ -372,6 +479,24 @@ def _contexts_for_versions(self, project_name, version_ids): return version_context_by_id, repre_context_by_id def _contexts_for_representations(self, project_name, repre_ids): + """Get contexts for given representation ids. + + Prepare version contexts for 'SubsetLoaderPlugin' and representation + contexts for 'LoaderPlugin' for all children representations of + given versions. + + This method is very similar to '_contexts_for_versions' but the + queries of documents are called in a different order. + + Args: + project_name (str): Project name. + repre_ids (Iterable[str]): Representation ids. + + Returns: + tuple[list[dict[str, Any]], list[dict[str, Any]]]: Version and + representation contexts. + """ + product_context_by_id = {} repre_context_by_id = {} if not project_name and not repre_ids: @@ -433,6 +558,18 @@ def _get_action_items_for_contexts( version_context_by_id, repre_context_by_id ): + """Prepare action items based on contexts. + + Actions are prepared based on discovered loader plugins and contexts. + The context must be valid for the loader plugin. + + Args: + project_name (str): Project name. + version_context_by_id (dict[str, dict[str, Any]]): Version + contexts by version id. + repre_context_by_id (dict[str, dict[str, Any]]): Representation + """ + action_items = [] if not version_context_by_id and not repre_context_by_id: return action_items @@ -507,6 +644,25 @@ def _trigger_version_loader( project_name, version_ids, ): + """Trigger version loader. + + This triggers 'load' method of 'SubsetLoaderPlugin' for given version + ids. + + Note: + Even when the plugin is 'SubsetLoaderPlugin' it actually expects + versions and should be named 'VersionLoaderPlugin'. Because it + is planned to refactor load system and introduce + 'LoaderAction' plugins it is not relevant to change it + anymore. + + Args: + loader (SubsetLoaderPlugin): Loader plugin to use. + options (dict): Option values for loader. + project_name (str): Project name. + version_ids (Iterable[str]): Version ids. + """ + project_doc = get_project(project_name) project_doc["code"] = project_doc["data"]["code"] @@ -541,6 +697,19 @@ def _trigger_representation_loader( project_name, representation_ids, ): + """Trigger representation loader. + + This triggers 'load' method of 'LoaderPlugin' for given representation + ids. For that are prepared contexts for each representation, with + all parent documents. + + Args: + loader (LoaderPlugin): Loader plugin to use. + options (dict): Option values for loader. + project_name (str): Project name. + representation_ids (Iterable[str]): Representation ids. + """ + project_doc = get_project(project_name) project_doc["code"] = project_doc["data"]["code"] repre_docs = list(get_representations( diff --git a/openpype/tools/ayon_loader/models/products.py b/openpype/tools/ayon_loader/models/products.py index 8579ec56234..33023cc1648 100644 --- a/openpype/tools/ayon_loader/models/products.py +++ b/openpype/tools/ayon_loader/models/products.py @@ -118,6 +118,16 @@ def product_type_item_from_data(product_type_data): class ProductsModel: + """Model for products, version and representation. + + All of the entities are product based. This model prepares data for UI + and caches it for faster access. + + Note: + Data are not used for actions model because that would require to + break OpenPype compatibility of 'LoaderPlugin's. + """ + lifetime = 60 # In seconds (minute by default) def __init__(self, controller): @@ -138,6 +148,8 @@ def __init__(self, controller): levels=2, default_factory=dict, lifetime=self.lifetime) def reset(self): + """Reset model with all cached data.""" + self._product_item_by_id.clear() self._version_item_by_id.clear() self._product_folder_ids_mapping.clear() @@ -147,6 +159,15 @@ def reset(self): self._repre_items_cache.reset() def get_product_type_items(self, project_name): + """Product type items for project. + + Args: + project_name (str): Project name. + + Returns: + list[ProductTypeItem]: Product type items. + """ + cache = self._product_type_items_cache[project_name] if not cache.is_valid: product_types = ayon_api.get_project_product_types(project_name) @@ -157,7 +178,15 @@ def get_product_type_items(self, project_name): return cache.get_data() def get_product_items(self, project_name, folder_ids, sender): - """ + """Product items with versions for project and folder ids. + + Product items also contain version items. They're directly connected + to product items in the UI and the separation is not needed. + + Args: + project_name (Union[str, None]): Project name. + folder_ids (Iterable[str]): Folder ids. + sender (Union[str, None]): Who triggered the method. Returns: list[ProductItem]: Product items. @@ -185,6 +214,19 @@ def get_product_items(self, project_name, folder_ids, sender): return output def get_product_item(self, project_name, product_id): + """Get product item based on passed product id. + + This method is using cached items, but if cache is not valid it also + can query the item. + + Args: + project_name (Union[str, None]): Where to look for product. + product_id (Union[str, None]): Product id to receive. + + Returns: + Union[ProductItem, None]: Product item or 'None' if not found. + """ + if not any((project_name, product_id)): return None @@ -222,7 +264,62 @@ def get_product_ids_by_repre_ids(self, project_name, repre_ids): ) return {v["productId"] for v in versions} + def get_repre_items(self, project_name, version_ids, sender): + """Get representation items for passed version ids. + + Args: + project_name (str): Project name. + version_ids (Iterable[str]): Version ids. + sender (Union[str, None]): Who triggered the method. + + Returns: + list[RepreItem]: Representation items. + """ + + output = [] + if not any((project_name, version_ids)): + return output + + invalid_version_ids = set() + project_cache = self._repre_items_cache[project_name] + for version_id in version_ids: + version_cache = project_cache[version_id] + if version_cache.is_valid: + output.extend(version_cache.get_data().values()) + else: + invalid_version_ids.add(version_id) + + if invalid_version_ids: + self.refresh_representation_items( + project_name, invalid_version_ids, sender + ) + + for version_id in invalid_version_ids: + version_cache = project_cache[version_id] + output.extend(version_cache.get_data().values()) + + return output + def change_products_group(self, project_name, product_ids, group_name): + """Change group name for passed product ids. + + Group name is stored in 'attrib' of product entity and is used in UI + to group items. + + Method triggers "products.group.changed" event with data: + { + "project_name": project_name, + "folder_ids": folder_ids, + "product_ids": product_ids, + "group_name": group_name + } + + Args: + project_name (str): Project name. + product_ids (Iterable[str]): Product ids to change group name for. + group_name (str): Group name to set. + """ + if not product_ids: return @@ -397,31 +494,6 @@ def _query_version_items_by_ids(self, project_name, version_ids): version_items.update(product_item.version_items) return version_items - def get_repre_items(self, project_name, version_ids, sender): - output = [] - if not any((project_name, version_ids)): - return output - - invalid_version_ids = set() - project_cache = self._repre_items_cache[project_name] - for version_id in version_ids: - version_cache = project_cache[version_id] - if version_cache.is_valid: - output.extend(version_cache.get_data().values()) - else: - invalid_version_ids.add(version_id) - - if invalid_version_ids: - self.refresh_representation_items( - project_name, invalid_version_ids, sender - ) - - for version_id in invalid_version_ids: - version_cache = project_cache[version_id] - output.extend(version_cache.get_data().values()) - - return output - def _clear_product_version_items(self, project_name, folder_ids): """Clear product and version items from memory. @@ -543,7 +615,7 @@ def refresh_representation_items( "version_ids": version_ids, "sender": sender, }, - "products.model" + PRODUCTS_MODEL_SENDER ) failed = False try: @@ -560,7 +632,7 @@ def refresh_representation_items( "sender": sender, "failed": failed, }, - "products.model" + PRODUCTS_MODEL_SENDER ) def _refresh_representation_items(self, project_name, version_ids): From 2a02cf390eae2ed4691c5577015d9a88ab973303 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 10 Oct 2023 16:06:55 +0200 Subject: [PATCH 64/69] first filter representation contexts and then create action items --- openpype/tools/ayon_loader/models/actions.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/openpype/tools/ayon_loader/models/actions.py b/openpype/tools/ayon_loader/models/actions.py index 3313ad5fde4..4fc8de61bff 100644 --- a/openpype/tools/ayon_loader/models/actions.py +++ b/openpype/tools/ayon_loader/models/actions.py @@ -587,21 +587,21 @@ def _get_action_items_for_contexts( # continue for repre_name, repre_contexts in repre_contexts_by_name.items(): + filtered_repre_contexts = filter_repre_contexts_by_loader( + repre_contexts, loader) + if len(filtered_repre_contexts) != len(repre_contexts): + continue + repre_ids = set() repre_version_ids = set() repre_product_ids = set() repre_folder_ids = set() - for repre_context in repre_context_by_id.values(): + for repre_context in filtered_repre_contexts: repre_ids.add(repre_context["representation"]["_id"]) repre_product_ids.add(repre_context["subset"]["_id"]) repre_version_ids.add(repre_context["version"]["_id"]) repre_folder_ids.add(repre_context["asset"]["_id"]) - filtered_repre_contexts = filter_repre_contexts_by_loader( - repre_contexts, loader) - if len(filtered_repre_contexts) != len(repre_contexts): - continue - item = self._create_loader_action_item( loader, repre_contexts, From e94926238f19832f9895aa13b9fe82e72c0a52e2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 10 Oct 2023 16:09:14 +0200 Subject: [PATCH 65/69] implemented 'refresh' method --- openpype/tools/ayon_loader/ui/window.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/tools/ayon_loader/ui/window.py b/openpype/tools/ayon_loader/ui/window.py index b35de73c457..6ad807d4626 100644 --- a/openpype/tools/ayon_loader/ui/window.py +++ b/openpype/tools/ayon_loader/ui/window.py @@ -320,6 +320,9 @@ def __init__(self, controller=None, parent=None): self._product_group_checkbox.isChecked() ) + def refresh(self): + self._controller.reset() + def showEvent(self, event): super(LoaderWindow, self).showEvent(event) From 5d85d3e8c4d1b3f44281b59d1e00b1b8bf6ff099 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 10 Oct 2023 16:10:15 +0200 Subject: [PATCH 66/69] do not reset controller in '_on_first_show' Method '_on_show_timer' will take about the reset. --- openpype/tools/ayon_loader/ui/window.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/tools/ayon_loader/ui/window.py b/openpype/tools/ayon_loader/ui/window.py index 6ad807d4626..c887e43c47c 100644 --- a/openpype/tools/ayon_loader/ui/window.py +++ b/openpype/tools/ayon_loader/ui/window.py @@ -367,7 +367,6 @@ def _on_first_show(self): ) self.setStyleSheet(load_stylesheet()) center_window(self) - self._controller.reset() def _on_show_timer(self): if self._show_counter < 2: From e2d37827dcdfa9707d0661b7dd104182a2613170 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 10 Oct 2023 16:27:18 +0200 Subject: [PATCH 67/69] 'ThumbnailPainterWidget' have more options of bg painting --- .../tools/utils/thumbnail_paint_widget.py | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/openpype/tools/utils/thumbnail_paint_widget.py b/openpype/tools/utils/thumbnail_paint_widget.py index 111adc81125..130942aaf02 100644 --- a/openpype/tools/utils/thumbnail_paint_widget.py +++ b/openpype/tools/utils/thumbnail_paint_widget.py @@ -41,6 +41,26 @@ def __init__(self, parent): self._current_pixes = None self._has_pixes = False + self._bg_color = QtCore.Qt.transparent + self._use_checker = True + self._checker_color_1 = QtGui.QColor(89, 89, 89) + self._checker_color_2 = QtGui.QColor(188, 187, 187) + + def set_background_color(self, color): + self._bg_color = color + self.repaint() + + def set_use_checkboard(self, use_checker): + if self._use_checker is use_checker: + return + self._use_checker = use_checker + self.repaint() + + def set_checker_colors(self, color_1, color_2): + self._checker_color_1 = color_1 + self._checker_color_2 = color_2 + self.repaint() + def set_border_color(self, color): """Change border color. @@ -128,7 +148,12 @@ def _get_default_pix(self): self._default_pix = default_pix return self._default_pix - def _paint_checker(self, width, height): + def _paint_tile(self, width, height): + if not self._use_checker: + tile_pix = QtGui.QPixmap(width, width) + tile_pix.fill(self._bg_color) + return tile_pix + checker_size = int(float(width) / self.checker_boxes_count) if checker_size < 1: checker_size = 1 @@ -138,11 +163,11 @@ def _paint_checker(self, width, height): checker_painter = QtGui.QPainter() checker_painter.begin(checker_pix) checker_painter.setPen(QtCore.Qt.NoPen) - checker_painter.setBrush(QtGui.QColor(89, 89, 89)) + checker_painter.setBrush(self._checker_color_1) checker_painter.drawRect( 0, 0, checker_pix.width(), checker_pix.height() ) - checker_painter.setBrush(QtGui.QColor(188, 187, 187)) + checker_painter.setBrush(self._checker_color_2) checker_painter.drawRect( 0, 0, checker_size, checker_size ) @@ -191,7 +216,7 @@ def _paint_default_pix(self, pix_width, pix_height): def _draw_thumbnails(self, thumbnails, pix_width, pix_height): full_border_width = 2 * self.border_width - checker_pix = self._paint_checker(pix_width, pix_height) + checker_pix = self._paint_tile(pix_width, pix_height) backgrounded_images = [] for src_pix in thumbnails: From 1e78d7238cfeecb065d1328f8e5c046e7acddc42 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 10 Oct 2023 16:27:42 +0200 Subject: [PATCH 68/69] do not use checkerboard in loader thumbnail --- openpype/tools/ayon_loader/ui/window.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/tools/ayon_loader/ui/window.py b/openpype/tools/ayon_loader/ui/window.py index c887e43c47c..ca17e4b9fd6 100644 --- a/openpype/tools/ayon_loader/ui/window.py +++ b/openpype/tools/ayon_loader/ui/window.py @@ -203,6 +203,7 @@ def __init__(self, controller=None, parent=None): right_panel_splitter.setOrientation(QtCore.Qt.Vertical) thumbnails_widget = ThumbnailPainterWidget(right_panel_splitter) + thumbnails_widget.set_use_checkboard(False) info_widget = InfoWidget(controller, right_panel_splitter) From 6ba7f410dd5b336ce7d6a95f83fee108193e3b7c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 10 Oct 2023 16:30:06 +0200 Subject: [PATCH 69/69] fix condition Co-authored-by: Roy Nieterau --- openpype/tools/ayon_loader/models/actions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/ayon_loader/models/actions.py b/openpype/tools/ayon_loader/models/actions.py index 4fc8de61bff..3edb04e9ebf 100644 --- a/openpype/tools/ayon_loader/models/actions.py +++ b/openpype/tools/ayon_loader/models/actions.py @@ -589,7 +589,7 @@ def _get_action_items_for_contexts( for repre_name, repre_contexts in repre_contexts_by_name.items(): filtered_repre_contexts = filter_repre_contexts_by_loader( repre_contexts, loader) - if len(filtered_repre_contexts) != len(repre_contexts): + if not filtered_repre_contexts: continue repre_ids = set()