diff --git a/client/ayon_core/cli.py b/client/ayon_core/cli.py index 322c294cfb..2340696ad9 100644 --- a/client/ayon_core/cli.py +++ b/client/ayon_core/cli.py @@ -235,6 +235,30 @@ def version(build): print(os.environ["AYON_VERSION"]) +@main_cli.command() +@click.option( + "--project", + type=str, + help="Project name", + required=True) +def create_project_structure( + project, +): + """Create project folder structure as defined in setting + `ayon+settings://core/project_folder_structure` + + Args: + project (str): The name of the project for which you + want to create its additional folder structure. + + """ + + from ayon_core.pipeline.project_folders import create_project_folders + + print(f">>> Creating project folder structure for project '{project}'.") + create_project_folders(project) + + def _set_global_environments() -> None: """Set global AYON environments.""" # First resolve general environment diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index 1dac8a4048..dc5bb0f66f 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -1,24 +1,28 @@ +"""Plugins for loading representations and products into host applications.""" from __future__ import annotations -import os -import logging -from typing import Any, Type, Optional -from abc import abstractmethod -from ayon_core.settings import get_project_settings +from abc import abstractmethod +import logging +import os +from typing import Any, Optional, Type + from ayon_core.pipeline.plugin_discover import ( + deregister_plugin, + deregister_plugin_path, discover, register_plugin, register_plugin_path, - deregister_plugin, - deregister_plugin_path ) +from ayon_core.settings import get_project_settings + from .utils import get_representation_path_from_context class LoaderPlugin(list): """Load representation into host application""" - product_types = set() + product_types: set[str] = set() + product_base_types: Optional[set[str]] = None representations = set() extensions = {"*"} order = 0 @@ -61,12 +65,12 @@ class LoaderPlugin(list): if not plugin_settings: return - print(">>> We have preset for {}".format(plugin_name)) + print(f">>> We have preset for {plugin_name}") for option, value in plugin_settings.items(): if option == "enabled" and value is False: print(" - is disabled by preset") else: - print(" - setting `{}`: `{}`".format(option, value)) + print(f" - setting `{option}`: `{value}`") setattr(cls, option, value) @classmethod @@ -79,7 +83,6 @@ class LoaderPlugin(list): Returns: bool: Representation has valid extension """ - if "*" in cls.extensions: return True @@ -124,18 +127,34 @@ class LoaderPlugin(list): """ plugin_repre_names = cls.get_representations() - plugin_product_types = cls.product_types + + # If the product base type isn't defined on the loader plugin, + # then we will use the product types. + plugin_product_filter = cls.product_base_types + if plugin_product_filter is None: + plugin_product_filter = cls.product_types + + if plugin_product_filter: + plugin_product_filter = set(plugin_product_filter) + + repre_entity = context.get("representation") + product_entity = context["product"] + + # If no representation names, product types or extensions are defined + # then loader is not compatible with any context. if ( not plugin_repre_names - or not plugin_product_types + or not plugin_product_filter or not cls.extensions ): return False - repre_entity = context.get("representation") + # If no representation entity is provided then loader is not + # compatible with context. if not repre_entity: return False + # Check the compatibility with the representation names. plugin_repre_names = set(plugin_repre_names) if ( "*" not in plugin_repre_names @@ -143,17 +162,34 @@ class LoaderPlugin(list): ): return False + # Check the compatibility with the extension of the representation. if not cls.has_valid_extension(repre_entity): return False - plugin_product_types = set(plugin_product_types) - if "*" in plugin_product_types: + product_type = product_entity.get("productType") + product_base_type = product_entity.get("productBaseType") + + # Use product base type if defined, otherwise use product type. + product_filter = product_base_type + # If there is no product base type defined in the product entity, + # then we will use the product type. + if product_filter is None: + product_filter = product_type + + # If wildcard is used in product types or base types, + # then we will consider the loader compatible with any product type. + if "*" in plugin_product_filter: return True - product_entity = context["product"] - product_type = product_entity["productType"] + # compatibility with legacy loader + if cls.product_base_types is None and product_base_type: + cls.log.error( + f"Loader {cls.__name__} is doesn't specify " + "`product_base_types` but product entity has " + f"`productBaseType` defined as `{product_base_type}`. " + ) - return product_type in plugin_product_types + return product_filter in plugin_product_filter @classmethod def get_representations(cls): @@ -208,19 +244,17 @@ class LoaderPlugin(list): bool: Whether the container was deleted """ - raise NotImplementedError("Loader.remove() must be " "implemented by subclass") @classmethod def get_options(cls, contexts): - """ - Returns static (cls) options or could collect from 'contexts'. + """Returns static (cls) options or could collect from 'contexts'. - Args: - contexts (list): of repre or product contexts - Returns: - (list) + Args: + contexts (list): of repre or product contexts + Returns: + (list) """ return cls.options or [] @@ -347,10 +381,8 @@ def discover_loader_plugins(project_name=None): plugin.apply_settings(project_settings) except Exception: log.warning( - "Failed to apply settings to loader {}".format( - plugin.__name__ - ), - exc_info=True, + f"Failed to apply settings to loader {plugin.__name__}", + exc_info=True ) compatible_hooks = [] for hook_cls in sorted_hooks: diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index 4ef903540e..b26d36fb7e 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -829,35 +829,47 @@ HintedLineEditButton { } /* Launcher specific stylesheets */ -ActionsView[mode="icon"] { +ActionsView { /* font size can't be set on items */ - font-size: 9pt; + font-size: 8pt; border: 0px; padding: 0px; margin: 0px; } -ActionsView[mode="icon"]::item { +ActionsView::item { padding-top: 8px; padding-bottom: 4px; border: 0px; - border-radius: 0.3em; + border-radius: 5px; } -ActionsView[mode="icon"]::item:hover { +ActionsView::item:hover { color: {color:font-hover}; background: #424A57; } -ActionsView[mode="icon"]::icon {} +ActionsView::icon {} + +ActionMenuPopup #GroupLabel { + padding: 5px; + color: #ffffff; +} + +ActionMenuPopup #ShadowFrame { + border-radius: 5px; + background: rgba(12, 13, 24, 0.5); +} ActionMenuPopup #Wrapper { - border-radius: 0.3em; + border-radius: 5px; background: #353B46; } -ActionMenuPopup ActionsView[mode="icon"] { + +ActionMenuPopup ActionsView { background: transparent; border: none; + margin: 4px; } #IconView[mode="icon"] { @@ -893,6 +905,70 @@ ActionMenuPopup ActionsView[mode="icon"] { border-radius: 0.1em; } +/* Launcher specific stylesheets */ +FiltersBar { + background: {color:bg-inputs}; + border: 1px solid {color:border}; + border-radius: 5px; +} + +FiltersBar #ScrollArea { + background: {color:bg-inputs}; +} +FiltersBar #SearchButton { + background: transparent; +} + +FiltersBar #BackButton { + background: transparent; +} + +FiltersBar #BackButton:hover { + background: {color:bg-buttons-hover}; +} + +FiltersBar #ConfirmButton { + background: #91CDFB; + color: #03344D; +} + +FiltersPopup #PopupWrapper, FilterValuePopup #PopupWrapper { + border-radius: 5px; + background: {color:bg-inputs}; +} + +FiltersPopup #ShadowFrame, FilterValuePopup #ShadowFrame { + border-radius: 5px; + background: rgba(0, 0, 0, 0.5); +} + +FilterItemButton, FilterValueItemButton { + border-radius: 5px; + background: transparent; +} +FilterItemButton:hover, FilterValueItemButton:hover { + background: {color:bg-buttons-hover}; +} +FilterValueItemButton[selected="1"] { + background: {color:bg-view-selection}; +} +FilterValueItemButton[selected="1"]:hover { + background: {color:bg-view-selection-hover}; +} +FilterValueItemsView #ContentWidget { + background: {color:bg-inputs}; +} +SearchItemDisplayWidget { + border-radius: 5px; +} +SearchItemDisplayWidget:hover { + background: {color:bg-buttons}; +} +SearchItemDisplayWidget #ValueWidget { + border-radius: 3px; + background: {color:bg-buttons}; +} + /* Subset Manager */ #SubsetManagerDetailsText {} #SubsetManagerDetailsText[state="invalid"] { diff --git a/client/ayon_core/tools/common_models/__init__.py b/client/ayon_core/tools/common_models/__init__.py index ece189fdc6..ec69e20b64 100644 --- a/client/ayon_core/tools/common_models/__init__.py +++ b/client/ayon_core/tools/common_models/__init__.py @@ -2,6 +2,7 @@ from .cache import CacheItem, NestedCacheItem from .projects import ( + TagItem, StatusItem, StatusStates, ProjectItem, @@ -25,6 +26,7 @@ __all__ = ( "CacheItem", "NestedCacheItem", + "TagItem", "StatusItem", "StatusStates", "ProjectItem", diff --git a/client/ayon_core/tools/common_models/hierarchy.py b/client/ayon_core/tools/common_models/hierarchy.py index 891eb80960..37d97af625 100644 --- a/client/ayon_core/tools/common_models/hierarchy.py +++ b/client/ayon_core/tools/common_models/hierarchy.py @@ -100,12 +100,14 @@ class TaskItem: label: Union[str, None], task_type: str, parent_id: str, + tags: list[str], ): self.task_id = task_id self.name = name self.label = label self.task_type = task_type self.parent_id = parent_id + self.tags = tags self._full_label = None @@ -145,6 +147,7 @@ class TaskItem: "label": self.label, "parent_id": self.parent_id, "task_type": self.task_type, + "tags": self.tags, } @classmethod @@ -176,7 +179,8 @@ def _get_task_items_from_tasks(tasks): task["name"], task["label"], task["type"], - folder_id + folder_id, + task["tags"], )) return output @@ -217,6 +221,8 @@ class HierarchyModel(object): lifetime = 60 # A minute def __init__(self, controller): + self._tags_by_entity_type = NestedCacheItem( + levels=1, default_factory=dict, lifetime=self.lifetime) self._folders_items = NestedCacheItem( levels=1, default_factory=dict, lifetime=self.lifetime) self._folders_by_id = NestedCacheItem( @@ -235,6 +241,7 @@ class HierarchyModel(object): self._controller = controller def reset(self): + self._tags_by_entity_type.reset() self._folders_items.reset() self._folders_by_id.reset() @@ -514,6 +521,31 @@ class HierarchyModel(object): return output + def get_available_tags_by_entity_type( + self, project_name: str + ) -> dict[str, list[str]]: + """Get available tags for all entity types in a project.""" + cache = self._tags_by_entity_type.get(project_name) + if not cache.is_valid: + tags = None + if project_name: + response = ayon_api.get(f"projects/{project_name}/tags") + if response.status_code == 200: + tags = response.data + + # Fake empty tags + if tags is None: + tags = { + "folders": [], + "tasks": [], + "products": [], + "versions": [], + "representations": [], + "workfiles": [] + } + cache.update_data(tags) + return cache.get_data() + @contextlib.contextmanager def _folder_refresh_event_manager(self, project_name, sender): self._folders_refreshing.add(project_name) @@ -617,6 +649,6 @@ class HierarchyModel(object): tasks = list(ayon_api.get_tasks( project_name, folder_ids=[folder_id], - fields={"id", "name", "label", "folderId", "type"} + fields={"id", "name", "label", "folderId", "type", "tags"} )) return _get_task_items_from_tasks(tasks) diff --git a/client/ayon_core/tools/common_models/projects.py b/client/ayon_core/tools/common_models/projects.py index f2599c9c9b..034947de3a 100644 --- a/client/ayon_core/tools/common_models/projects.py +++ b/client/ayon_core/tools/common_models/projects.py @@ -1,4 +1,5 @@ from __future__ import annotations + import contextlib from abc import ABC, abstractmethod from typing import Dict, Any @@ -74,6 +75,13 @@ class StatusItem: ) +@dataclass +class TagItem: + """Tag definition set on project anatomy.""" + name: str + color: str + + class FolderTypeItem: """Item representing folder type of project. @@ -292,6 +300,22 @@ class ProjectsModel(object): project_cache.update_data(entity) return project_cache.get_data() + def get_project_anatomy_tags(self, project_name: str) -> list[TagItem]: + """Get project anatomy tags. + + Args: + project_name (str): Project name. + + Returns: + list[TagItem]: Tag definitions. + + """ + project_entity = self.get_project_entity(project_name) + return [ + TagItem(tag["name"], tag["color"]) + for tag in project_entity["tags"] + ] + def get_project_status_items(self, project_name, sender): """Get project status items. diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 0459999958..51cb8e73bc 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -16,12 +16,12 @@ from ayon_core.lib.attribute_definitions import ( from ayon_core.tools.flickcharm import FlickCharm from ayon_core.tools.utils import ( get_qt_icon, - PixmapLabel, ) from ayon_core.tools.attribute_defs import AttributeDefinitionsDialog from ayon_core.tools.launcher.abstract import WebactionContext ANIMATION_LEN = 7 +SHADOW_FRAME_MARGINS = (1, 1, 1, 1) ACTION_ID_ROLE = QtCore.Qt.UserRole + 1 ACTION_TYPE_ROLE = QtCore.Qt.UserRole + 2 @@ -51,13 +51,9 @@ def _variant_label_sort_getter(action_item): # --- Replacement for QAction for action variants --- -class LauncherSettingsLabel(PixmapLabel): +class LauncherSettingsLabel(QtWidgets.QWidget): _settings_icon = None - def __init__(self, parent): - icon = self._get_settings_icon() - super().__init__(icon.pixmap(64, 64), parent) - @classmethod def _get_settings_icon(cls): if cls._settings_icon is None: @@ -67,24 +63,52 @@ class LauncherSettingsLabel(PixmapLabel): }) return cls._settings_icon + def paintEvent(self, event): + painter = QtGui.QPainter(self) + + painter.setRenderHints( + QtGui.QPainter.Antialiasing + | QtGui.QPainter.SmoothPixmapTransform + ) + + rect = event.rect() + size = min(rect.height(), rect.width()) + pix_rect = QtCore.QRect( + rect.x(), rect.y(), + size, size + ) + pix = self._get_settings_icon().pixmap(size, size) + painter.drawPixmap(pix_rect, pix) + + painter.end() + class ActionOverlayWidget(QtWidgets.QFrame): - config_requested = QtCore.Signal(str) - def __init__(self, item_id, parent): super().__init__(parent) self._item_id = item_id settings_icon = LauncherSettingsLabel(self) settings_icon.setToolTip("Right click for options") + settings_icon.setVisible(False) main_layout = QtWidgets.QGridLayout(self) main_layout.setContentsMargins(5, 5, 0, 0) main_layout.addWidget(settings_icon, 0, 0) - main_layout.setColumnStretch(1, 1) - main_layout.setRowStretch(1, 1) + main_layout.setColumnStretch(0, 1) + main_layout.setColumnStretch(1, 5) - self.setAttribute(QtCore.Qt.WA_TranslucentBackground) + self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + + self._settings_icon = settings_icon + + def enterEvent(self, event): + super().enterEvent(event) + self._settings_icon.setVisible(True) + + def leaveEvent(self, event): + super().leaveEvent(event) + self._settings_icon.setVisible(False) class ActionsQtModel(QtGui.QStandardItemModel): @@ -138,6 +162,12 @@ class ActionsQtModel(QtGui.QStandardItemModel): def get_item_by_id(self, action_id): return self._items_by_id.get(action_id) + def get_index_by_id(self, action_id): + item = self.get_item_by_id(action_id) + if item is not None: + return self.indexFromItem(item) + return QtCore.QModelIndex() + def get_group_item_by_action_id(self, action_id): item = self._items_by_id.get(action_id) if item is not None: @@ -222,7 +252,7 @@ class ActionsQtModel(QtGui.QStandardItemModel): item.setFlags(QtCore.Qt.ItemIsEnabled) item.setData(label, QtCore.Qt.DisplayRole) - # item.setData(label, QtCore.Qt.ToolTipRole) + item.setData(label, QtCore.Qt.ToolTipRole) item.setData(icon, QtCore.Qt.DecorationRole) item.setData(is_group, ACTION_IS_GROUP_ROLE) item.setData(has_configs, ACTION_HAS_CONFIGS_ROLE) @@ -295,8 +325,8 @@ class ActionMenuPopupModel(QtGui.QStandardItemModel): item = QtGui.QStandardItem() item.setFlags(QtCore.Qt.ItemIsEnabled) - # item.setData(action_item.full_label, QtCore.Qt.ToolTipRole) - item.setData(action_item.full_label, QtCore.Qt.DisplayRole) + item.setData(action_item.variant_label, QtCore.Qt.DisplayRole) + item.setData(action_item.full_label, QtCore.Qt.ToolTipRole) item.setData(icon, QtCore.Qt.DecorationRole) item.setData(action_item.identifier, ACTION_ID_ROLE) item.setData( @@ -344,8 +374,24 @@ class ActionMenuPopupModel(QtGui.QStandardItemModel): class ActionMenuPopup(QtWidgets.QWidget): + """Popup widget for group varaints. + + The popup is handling most of the layout and showing of the items + manually. + + There 4 parts: + 1. Shadow - semi transparent black widget used as shadow. + 2. Background - painted over the shadow with blur effect. All + other items are painted over. + 3. Label - show group label and positioned manually at the top + of the popup. + 4. View - View with variant action items. View is positioned + and resized manually according to the items in the group and then + animated using mask region. + + """ action_triggered = QtCore.Signal(str) - config_requested = QtCore.Signal(str) + config_requested = QtCore.Signal(str, QtCore.QPoint) def __init__(self, parent): super().__init__(parent) @@ -363,16 +409,34 @@ class ActionMenuPopup(QtWidgets.QWidget): expand_anim.setDuration(60) expand_anim.setEasingCurve(QtCore.QEasingCurve.InOutQuad) + sh_l, sh_t, sh_r, sh_b = SHADOW_FRAME_MARGINS + + group_label = QtWidgets.QLabel("|", self) + group_label.setObjectName("GroupLabel") + # View with actions view = ActionsView(self) view.setGridSize(QtCore.QSize(75, 80)) view.setIconSize(QtCore.QSize(32, 32)) - view.move(QtCore.QPoint(3, 3)) + view.move(sh_l, sh_t) + + view.stackUnder(group_label) # Background draw + bg_frame = QtWidgets.QFrame(self) + bg_frame.setObjectName("ShadowFrame") + bg_frame.stackUnder(view) + wrapper = QtWidgets.QFrame(self) wrapper.setObjectName("Wrapper") - wrapper.stackUnder(view) + + effect = QtWidgets.QGraphicsBlurEffect(wrapper) + effect.setBlurRadius(3.0) + wrapper.setGraphicsEffect(effect) + + bg_layout = QtWidgets.QVBoxLayout(bg_frame) + bg_layout.setContentsMargins(sh_l, sh_t, sh_r, sh_b) + bg_layout.addWidget(wrapper) model = ActionMenuPopupModel() proxy_model = ActionsProxyModel() @@ -387,10 +451,12 @@ class ActionMenuPopup(QtWidgets.QWidget): expand_anim.finished.connect(self._on_expand_finish) view.clicked.connect(self._on_clicked) - view.config_requested.connect(self.config_requested) + view.config_requested.connect(self._on_configs_trigger) + self._group_label = group_label self._view = view - self._wrapper = wrapper + self._bg_frame = bg_frame + self._effect = effect self._model = model self._proxy_model = proxy_model @@ -417,7 +483,8 @@ class ActionMenuPopup(QtWidgets.QWidget): super().leaveEvent(event) self._close_timer.start() - def show_items(self, action_id, action_items, pos): + def show_items(self, group_label, action_id, action_items, pos): + self._group_label.setText(group_label) if not action_items: if self._showed: self._close_timer.start() @@ -426,70 +493,82 @@ class ActionMenuPopup(QtWidgets.QWidget): self._close_timer.stop() - update_position = False if action_id != self._current_id: - update_position = True + self.setGeometry(pos.x(), pos.y(), 1, 1) self._current_id = action_id self._update_items(action_items) # Make sure is visible if not self._showed: - update_position = True self.show() - if not update_position: - self.raise_() - return - # Set geometry to position # - first make sure widget changes from '_update_items' # are recalculated app = QtWidgets.QApplication.instance() app.processEvents() - items_count, size, target_size = self._get_size_hint() + items_count, start_size, target_size = self._get_size_hint() self._model.fill_to_count(items_count) + label_sh = self._group_label.sizeHint() + label_width, label_height = label_sh.width(), label_sh.height() window = self.screen() window_geo = window.geometry() + _target_x = pos.x() + target_size.width() + _target_y = pos.y() + target_size.height() + label_height right_to_left = ( - pos.x() + target_size.width() > window_geo.right() - or pos.y() + target_size.height() > window_geo.bottom() + _target_x > window_geo.right() + or _target_y > window_geo.bottom() ) - pos_x = pos.x() - 5 - pos_y = pos.y() - 4 - - wrap_x = wrap_y = 0 + sh_l, sh_t, sh_r, sh_b = SHADOW_FRAME_MARGINS + viewport_offset = self._view.viewport().geometry().topLeft() + pos_x = pos.x() - (sh_l + viewport_offset.x() + 2) + pos_y = pos.y() - (sh_t + viewport_offset.y() + 1) + bg_x = bg_y = 0 sort_order = QtCore.Qt.DescendingOrder if right_to_left: sort_order = QtCore.Qt.AscendingOrder - size_diff = target_size - size + size_diff = target_size - start_size pos_x -= size_diff.width() pos_y -= size_diff.height() - wrap_x = size_diff.width() - wrap_y = size_diff.height() + bg_x = size_diff.width() + bg_y = size_diff.height() - label_height - wrap_geo = QtCore.QRect( - wrap_x, wrap_y, size.width(), size.height() + bg_geo = QtCore.QRect( + bg_x, bg_y, + start_size.width(), start_size.height() + label_height ) + + label_pos_x = sh_l + label_pos_y = bg_y + sh_t + if label_width < start_size.width(): + label_pos_x = bg_x + (start_size.width() - label_width) // 2 + if self._expand_anim.state() == QtCore.QAbstractAnimation.Running: self._expand_anim.stop() - self._first_anim_frame = True + self._right_to_left = right_to_left self._proxy_model.sort(0, sort_order) self.setUpdatesEnabled(False) - self._view.setMask(wrap_geo) + self._view.setMask( + bg_geo.adjusted(sh_l, sh_t, -sh_r, -sh_b) + ) self._view.setMinimumWidth(target_size.width()) self._view.setMaximumWidth(target_size.width()) - self._wrapper.setGeometry(wrap_geo) + self._view.setMinimumHeight(target_size.height()) + self._view.move(sh_l, sh_t + label_height) self.setGeometry( - pos_x, pos_y, - target_size.width(), target_size.height() + pos_x, pos_y - label_height, + target_size.width(), target_size.height() + label_height ) + self._bg_frame.setGeometry(bg_geo) + self._group_label.move(label_pos_x, label_pos_y) self.setUpdatesEnabled(True) + self._expand_anim.updateCurrentTime(0) - self._expand_anim.setStartValue(size) + self._expand_anim.setStartValue(start_size) self._expand_anim.setEndValue(target_size) self._expand_anim.start() @@ -511,20 +590,37 @@ class ActionMenuPopup(QtWidgets.QWidget): self._expand_anim.stop() return - wrapper_geo = self._wrapper.geometry() - wrapper_geo.setWidth(value.width()) - wrapper_geo.setHeight(value.height()) + bg_geo = self._bg_frame.geometry() + label_sh = self._group_label.sizeHint() + label_width, label_height = label_sh.width(), label_sh.height() if self._right_to_left: - geo = self.geometry() + popup_geo = self.geometry() + diff_size = popup_geo.size() - value pos = QtCore.QPoint( - geo.width() - value.width(), - geo.height() - value.height(), + diff_size.width(), diff_size.height() - label_height ) - wrapper_geo.setTopLeft(pos) - self._view.setMask(wrapper_geo) - self._wrapper.setGeometry(wrapper_geo) + bg_geo.moveTopLeft(pos) + + bg_geo.setWidth(value.width()) + bg_geo.setHeight(value.height() + label_height) + + label_width = self._group_label.sizeHint().width() + bgeo_tl = bg_geo.topLeft() + sh_l, sh_t, sh_r, sh_b = SHADOW_FRAME_MARGINS + + label_pos_x = sh_l + if label_width < value.width(): + label_pos_x = bgeo_tl.x() + (value.width() - label_width) // 2 + + self.setUpdatesEnabled(False) + self._view.setMask( + bg_geo.adjusted(sh_l, sh_t, -sh_r, -sh_b) + ) + self._group_label.move(label_pos_x, sh_t) + self._bg_frame.setGeometry(bg_geo) + self.setUpdatesEnabled(True) def _on_expand_finish(self): # Make sure that size is recalculated if src and targe size is same @@ -547,16 +643,25 @@ class ActionMenuPopup(QtWidgets.QWidget): if rows == 1: cols = row_count - m_l, m_t, m_r, m_b = (3, 3, 1, 1) - # QUESTION how to get the margins from Qt? - border = 2 * 1 + viewport_geo = self._view.viewport().geometry() + viewport_offset = viewport_geo.topLeft() + # QUESTION how to get the bottom and right margins from Qt? + vp_lr = viewport_offset.x() + vp_tb = viewport_offset.y() + m_l, m_t, m_r, m_b = ( + s_m + vp_m + for s_m, vp_m in zip( + SHADOW_FRAME_MARGINS, + (vp_lr, vp_tb, vp_lr, vp_tb) + ) + ) single_width = ( grid_size.width() - + self._view.horizontalOffset() + border + m_l + m_r + 1 + + self._view.horizontalOffset() + m_l + m_r + 1 ) single_height = ( grid_size.height() - + self._view.verticalOffset() + border + m_b + m_t + 1 + + self._view.verticalOffset() + m_b + m_t + 1 ) total_width = single_width total_height = single_height @@ -586,14 +691,13 @@ class ActionMenuPopup(QtWidgets.QWidget): self.action_triggered.emit(action_id) self.close() - def _on_configs_trigger(self, action_id): - self.config_requested.emit(action_id) + def _on_configs_trigger(self, action_id, center_pos): + self.config_requested.emit(action_id, center_pos) self.close() class ActionDelegate(QtWidgets.QStyledItemDelegate): - _cached_extender = {} - _cached_extender_base_pix = None + _extender_icon = None def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -653,31 +757,18 @@ class ActionDelegate(QtWidgets.QStyledItemDelegate): painter.restore() @classmethod - def _get_extender_pixmap(cls, size): - pix = cls._cached_extender.get(size) - if pix is not None: - return pix - - base_pix = cls._cached_extender_base_pix - if base_pix is None: - icon = get_qt_icon({ + def _get_extender_pixmap(cls): + if cls._extender_icon is None: + cls._extender_icon = get_qt_icon({ "type": "material-symbols", "name": "more_horiz", }) - base_pix = icon.pixmap(64, 64) - cls._cached_extender_base_pix = base_pix - - pix = base_pix.scaled( - size, size, - QtCore.Qt.KeepAspectRatio, - QtCore.Qt.SmoothTransformation - ) - cls._cached_extender[size] = pix - return pix + return cls._extender_icon def paint(self, painter, option, index): painter.setRenderHints( QtGui.QPainter.Antialiasing + | QtGui.QPainter.TextAntialiasing | QtGui.QPainter.SmoothPixmapTransform ) @@ -690,20 +781,15 @@ class ActionDelegate(QtWidgets.QStyledItemDelegate): return grid_size = option.widget.gridSize() - x_offset = int( - (grid_size.width() / 2) - - (option.rect.width() / 2) - ) - item_x = option.rect.x() - x_offset - tenth_size = int(grid_size.width() / 10) - extender_size = int(tenth_size * 2.4) + extender_rect = option.rect.adjusted(5, 5, 0, 0) + extender_size = grid_size.width() // 6 + extender_rect.setWidth(extender_size) + extender_rect.setHeight(extender_size) - extender_x = item_x + tenth_size - extender_y = option.rect.y() + tenth_size - - pix = self._get_extender_pixmap(extender_size) - painter.drawPixmap(extender_x, extender_y, pix) + icon = self._get_extender_pixmap() + pix = icon.pixmap(extender_size, extender_size) + painter.drawPixmap(extender_rect, pix) class ActionsProxyModel(QtCore.QSortFilterProxyModel): @@ -739,12 +825,10 @@ class ActionsProxyModel(QtCore.QSortFilterProxyModel): class ActionsView(QtWidgets.QListView): - action_triggered = QtCore.Signal(str) - config_requested = QtCore.Signal(str) + config_requested = QtCore.Signal(str, QtCore.QPoint) def __init__(self, parent): super().__init__(parent) - self.setProperty("mode", "icon") self.setViewMode(QtWidgets.QListView.IconMode) self.setResizeMode(QtWidgets.QListView.Adjust) self.setSelectionMode(QtWidgets.QListView.NoSelection) @@ -773,18 +857,6 @@ class ActionsView(QtWidgets.QListView): self._overlay_widgets = [] self._flick = flick self._delegate = delegate - self._popup_widget = None - - def mouseMoveEvent(self, event): - """Handle mouse move event.""" - super().mouseMoveEvent(event) - # Update hover state for the item under mouse - index = self.indexAt(event.pos()) - if index.isValid() and index.data(ACTION_IS_GROUP_ROLE): - self._show_group_popup(index) - - elif self._popup_widget is not None: - self._popup_widget.close() def _on_context_menu(self, point): """Creates menu to force skip opening last workfile.""" @@ -792,34 +864,9 @@ class ActionsView(QtWidgets.QListView): if not index.isValid(): return action_id = index.data(ACTION_ID_ROLE) - self.config_requested.emit(action_id) - - def _get_popup_widget(self): - if self._popup_widget is None: - popup_widget = ActionMenuPopup(self) - - popup_widget.action_triggered.connect(self.action_triggered) - popup_widget.config_requested.connect(self.config_requested) - self._popup_widget = popup_widget - return self._popup_widget - - def _show_group_popup(self, index): - action_id = index.data(ACTION_ID_ROLE) - model = self.model() - while hasattr(model, "sourceModel"): - model = model.sourceModel() - - if not hasattr(model, "get_group_items"): - return - - action_items = model.get_group_items(action_id) rect = self.visualRect(index) - pos = self.mapToGlobal(rect.topLeft()) - - popup_widget = self._get_popup_widget() - popup_widget.show_items( - action_id, action_items, pos - ) + global_center = self.mapToGlobal(rect.center()) + self.config_requested.emit(action_id, global_center) def update_on_refresh(self): viewport = self.viewport() @@ -837,9 +884,6 @@ class ActionsView(QtWidgets.QListView): if has_configs: item_id = index.data(ACTION_ID_ROLE) widget = ActionOverlayWidget(item_id, viewport) - widget.config_requested.connect( - self.config_requested - ) overlay_widgets.append(widget) self.setIndexWidget(index, widget) @@ -877,8 +921,7 @@ class ActionsWidget(QtWidgets.QWidget): animation_timer.timeout.connect(self._on_animation) view.clicked.connect(self._on_clicked) - view.action_triggered.connect(self._trigger_action) - view.config_requested.connect(self._on_config_request) + view.config_requested.connect(self._show_config_dialog) model.refreshed.connect(self._on_model_refresh) self._animated_items = set() @@ -888,6 +931,8 @@ class ActionsWidget(QtWidgets.QWidget): self._model = model self._proxy_model = proxy_model + self._popup_widget = None + self._set_row_height(1) def refresh(self): @@ -974,10 +1019,32 @@ class ActionsWidget(QtWidgets.QWidget): return is_group = index.data(ACTION_IS_GROUP_ROLE) - if is_group: - return action_id = index.data(ACTION_ID_ROLE) - self._trigger_action(action_id, index) + if is_group: + self._show_group_popup(index) + else: + self._trigger_action(action_id, index) + + def _get_popup_widget(self): + if self._popup_widget is None: + popup_widget = ActionMenuPopup(self) + + popup_widget.action_triggered.connect(self._trigger_action) + popup_widget.config_requested.connect(self._show_config_dialog) + self._popup_widget = popup_widget + return self._popup_widget + + def _show_group_popup(self, index): + action_id = index.data(ACTION_ID_ROLE) + group_label = index.data(QtCore.Qt.DisplayRole) + action_items = self._model.get_group_items(action_id) + rect = self._view.visualRect(index) + pos = self.mapToGlobal(rect.topLeft()) + + popup_widget = self._get_popup_widget() + popup_widget.show_items( + group_label, action_id, action_items, pos + ) def _trigger_action(self, action_id, index=None): project_name = self._model.get_selected_project_name() @@ -1011,10 +1078,7 @@ class ActionsWidget(QtWidgets.QWidget): if index is not None: self._start_animation(index) - def _on_config_request(self, action_id): - self._show_config_dialog(action_id) - - def _show_config_dialog(self, action_id): + def _show_config_dialog(self, action_id, center_point): action_item = self._model.get_action_item_by_id(action_id) config_fields = self._model.get_action_config_fields(action_id) if not config_fields: @@ -1040,11 +1104,31 @@ class ActionsWidget(QtWidgets.QWidget): "Cancel", ) dialog.set_values(values) + dialog.show() + self._center_dialog(dialog, center_point) result = dialog.exec_() if result == QtWidgets.QDialog.Accepted: new_values = dialog.get_values() self._controller.set_action_config_values(context, new_values) + @staticmethod + def _center_dialog(dialog, target_center_pos): + dialog_geo = dialog.geometry() + dialog_geo.moveCenter(target_center_pos) + + screen = dialog.screen() + screen_geo = screen.availableGeometry() + if screen_geo.left() > dialog_geo.left(): + dialog_geo.moveLeft(screen_geo.left()) + elif screen_geo.right() < dialog_geo.right(): + dialog_geo.moveRight(screen_geo.right()) + + if screen_geo.top() > dialog_geo.top(): + dialog_geo.moveTop(screen_geo.top()) + elif screen_geo.bottom() < dialog_geo.bottom(): + dialog_geo.moveBottom(screen_geo.bottom()) + dialog.move(dialog_geo.topLeft()) + def _create_attrs_dialog( self, config_fields, diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index d0d7cd430b..5ab7e78212 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -1,11 +1,15 @@ +"""Abstract base classes for loader tool.""" +from __future__ import annotations + from abc import ABC, abstractmethod -from typing import List +from typing import Iterable, Any, Optional from ayon_core.lib.attribute_definitions import ( AbstractAttrDef, - serialize_attr_defs, deserialize_attr_defs, + serialize_attr_defs, ) +from ayon_core.tools.common_models import TaskItem, TagItem class ProductTypeItem: @@ -16,7 +20,7 @@ class ProductTypeItem: icon (dict[str, Any]): Product type icon definition. """ - def __init__(self, name, icon): + def __init__(self, name: str, icon: dict[str, Any]): self.name = name self.icon = icon @@ -31,6 +35,41 @@ class ProductTypeItem: return cls(**data) +class ProductBaseTypeItem: + """Item representing the product base type.""" + + def __init__(self, name: str, icon: dict[str, Any]): + """Initialize product base type item.""" + self.name = name + self.icon = icon + + def to_data(self) -> dict[str, Any]: + """Convert item to data dictionary. + + Returns: + dict[str, Any]: Data representation of the item. + + """ + return { + "name": self.name, + "icon": self.icon, + } + + @classmethod + def from_data( + cls, data: dict[str, Any]) -> ProductBaseTypeItem: + """Create item from data dictionary. + + Args: + data (dict[str, Any]): Data to create item from. + + Returns: + ProductBaseTypeItem: Item created from the provided data. + + """ + return cls(**data) + + class ProductItem: """Product item with it versions. @@ -49,35 +88,41 @@ class ProductItem: def __init__( self, - product_id, - product_type, - product_name, - product_icon, - product_type_icon, - product_in_scene, - group_name, - folder_id, - folder_label, - version_items, + product_id: str, + product_type: str, + product_base_type: str, + product_name: str, + product_icon: dict[str, Any], + product_type_icon: dict[str, Any], + product_base_type_icon: dict[str, Any], + group_name: str, + folder_id: str, + folder_label: str, + version_items: dict[str, VersionItem], + product_in_scene: bool, ): self.product_id = product_id self.product_type = product_type + self.product_base_type = product_base_type self.product_name = product_name self.product_icon = product_icon self.product_type_icon = product_type_icon + self.product_base_type_icon = product_base_type_icon self.product_in_scene = product_in_scene self.group_name = group_name self.folder_id = folder_id self.folder_label = folder_label self.version_items = version_items - def to_data(self): + def to_data(self) -> dict[str, Any]: return { "product_id": self.product_id, "product_type": self.product_type, + "product_base_type": self.product_base_type, "product_name": self.product_name, "product_icon": self.product_icon, "product_type_icon": self.product_type_icon, + "product_base_type_icon": self.product_base_type_icon, "product_in_scene": self.product_in_scene, "group_name": self.group_name, "folder_id": self.folder_id, @@ -113,6 +158,7 @@ class VersionItem: published_time (Union[str, None]): Published time in format '%Y%m%dT%H%M%SZ'. status (Union[str, None]): Status name. + tags (Union[list[str], None]): Tags. author (Union[str, None]): Author. frame_range (Union[str, None]): Frame range. duration (Union[int, None]): Duration. @@ -124,21 +170,22 @@ class VersionItem: def __init__( self, - version_id, - version, - is_hero, - product_id, - task_id, - thumbnail_id, - published_time, - author, - status, - frame_range, - duration, - handles, - step, - comment, - source, + version_id: str, + version: int, + is_hero: bool, + product_id: str, + task_id: Optional[str], + thumbnail_id: Optional[str], + published_time: Optional[str], + tags: Optional[list[str]], + author: Optional[str], + status: Optional[str], + frame_range: Optional[str], + duration: Optional[int], + handles: Optional[str], + step: Optional[int], + comment: Optional[str], + source: Optional[str], ): self.version_id = version_id self.product_id = product_id @@ -148,6 +195,7 @@ class VersionItem: self.is_hero = is_hero self.published_time = published_time self.author = author + self.tags = tags self.status = status self.frame_range = frame_range self.duration = duration @@ -198,7 +246,7 @@ class VersionItem: def __le__(self, other): return self.__eq__(other) or self.__lt__(other) - def to_data(self): + def to_data(self) -> dict[str, Any]: return { "version_id": self.version_id, "product_id": self.product_id, @@ -208,6 +256,7 @@ class VersionItem: "is_hero": self.is_hero, "published_time": self.published_time, "author": self.author, + "tags": self.tags, "status": self.status, "frame_range": self.frame_range, "duration": self.duration, @@ -218,7 +267,7 @@ class VersionItem: } @classmethod - def from_data(cls, data): + def from_data(cls, data: dict[str, Any]) -> VersionItem: return cls(**data) @@ -354,8 +403,8 @@ class ProductTypesFilter: Defines the filtering for product types. """ - def __init__(self, product_types: List[str], is_allow_list: bool): - self.product_types: List[str] = product_types + def __init__(self, product_types: list[str], is_allow_list: bool): + self.product_types: list[str] = product_types self.is_allow_list: bool = is_allow_list @@ -517,8 +566,21 @@ class FrontendLoaderController(_BaseLoaderController): Returns: list[ProjectItem]: List of project items. - """ + """ + pass + + @abstractmethod + def get_project_anatomy_tags(self, project_name: str) -> list[TagItem]: + """Tag items defined on project anatomy. + + Args: + project_name (str): Project name. + + Returns: + list[TagItem]: Tag definition items. + + """ pass @abstractmethod @@ -542,7 +604,12 @@ class FrontendLoaderController(_BaseLoaderController): pass @abstractmethod - def get_task_items(self, project_name, folder_ids, sender=None): + def get_task_items( + self, + project_name: str, + folder_ids: Iterable[str], + sender: Optional[str] = None, + ) -> list[TaskItem]: """Task items for folder ids. Args: @@ -590,6 +657,21 @@ class FrontendLoaderController(_BaseLoaderController): """ pass + @abstractmethod + def get_available_tags_by_entity_type( + self, project_name: str + ) -> dict[str, list[str]]: + """Get available tags by entity type. + + Args: + project_name (str): Project name. + + Returns: + dict[str, list[str]]: Available tags by entity type. + + """ + pass + @abstractmethod def get_project_status_items(self, project_name, sender=None): """Items for all projects available on server. diff --git a/client/ayon_core/tools/loader/control.py b/client/ayon_core/tools/loader/control.py index b3a80b34d4..95f48b3519 100644 --- a/client/ayon_core/tools/loader/control.py +++ b/client/ayon_core/tools/loader/control.py @@ -13,6 +13,7 @@ from ayon_core.tools.common_models import ( ProjectsModel, HierarchyModel, ThumbnailsModel, + TagItem, ) from .abstract import ( @@ -223,6 +224,16 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): output[folder_id] = label return output + def get_available_tags_by_entity_type( + self, project_name: str + ) -> dict[str, list[str]]: + return self._hierarchy_model.get_available_tags_by_entity_type( + project_name + ) + + def get_project_anatomy_tags(self, project_name: str) -> list[TagItem]: + return self._projects_model.get_project_anatomy_tags(project_name) + def get_product_items(self, project_name, folder_ids, sender=None): return self._products_model.get_product_items( project_name, folder_ids, sender) diff --git a/client/ayon_core/tools/loader/models/products.py b/client/ayon_core/tools/loader/models/products.py index 34acc0550c..edf8efc3b3 100644 --- a/client/ayon_core/tools/loader/models/products.py +++ b/client/ayon_core/tools/loader/models/products.py @@ -1,24 +1,34 @@ +"""Products model for loader tools.""" +from __future__ import annotations import collections import contextlib +from typing import TYPE_CHECKING, Iterable, Optional import arrow import ayon_api from ayon_api.operations import OperationsSession + from ayon_core.lib import NestedCacheItem from ayon_core.style import get_default_entity_icon_color from ayon_core.tools.loader.abstract import ( ProductTypeItem, + ProductBaseTypeItem, ProductItem, VersionItem, RepreItem, ) +if TYPE_CHECKING: + from ayon_api.typing import ProductBaseTypeDict, ProductDict, VersionDict + + PRODUCTS_MODEL_SENDER = "products.model" def version_item_from_entity(version): version_attribs = version["attrib"] + tags = version["tags"] frame_start = version_attribs.get("frameStart") frame_end = version_attribs.get("frameEnd") handle_start = version_attribs.get("handleStart") @@ -59,6 +69,7 @@ def version_item_from_entity(version): thumbnail_id=version["thumbnailId"], published_time=published_time, author=author, + tags=tags, status=version["status"], frame_range=frame_range, duration=duration, @@ -70,9 +81,10 @@ def version_item_from_entity(version): def product_item_from_entity( - product_entity, + product_entity: ProductDict, version_entities, - product_type_items_by_name, + product_type_items_by_name: dict[str, ProductTypeItem], + product_base_type_items_by_name: dict[str, ProductBaseTypeItem], folder_label, product_in_scene, ): @@ -88,8 +100,20 @@ def product_item_from_entity( # Cache the item for future use product_type_items_by_name[product_type] = product_type_item - product_type_icon = product_type_item.icon + product_base_type = product_entity.get("productBaseType") + product_base_type_item = product_base_type_items_by_name.get( + product_base_type) + # Same as for product type item above. Not sure if this is still needed + # though. + if product_base_type_item is None: + product_base_type_item = create_default_product_base_type_item( + product_base_type) + # Cache the item for future use + product_base_type_items_by_name[product_base_type] = ( + product_base_type_item) + product_type_icon = product_type_item.icon + product_base_type_icon = product_base_type_item.icon product_icon = { "type": "awesome-font", "name": "fa.file-o", @@ -103,9 +127,11 @@ def product_item_from_entity( return ProductItem( product_id=product_entity["id"], product_type=product_type, + product_base_type=product_base_type, product_name=product_entity["name"], product_icon=product_icon, product_type_icon=product_type_icon, + product_base_type_icon=product_base_type_icon, product_in_scene=product_in_scene, group_name=group, folder_id=product_entity["folderId"], @@ -114,7 +140,8 @@ def product_item_from_entity( ) -def product_type_item_from_data(product_type_data): +def product_type_item_from_data( + product_type_data: ProductDict) -> ProductTypeItem: # TODO implement icon implementation # icon = product_type_data["icon"] # color = product_type_data["color"] @@ -127,7 +154,29 @@ def product_type_item_from_data(product_type_data): return ProductTypeItem(product_type_data["name"], icon) -def create_default_product_type_item(product_type): +def product_base_type_item_from_data( + product_base_type_data: ProductBaseTypeDict +) -> ProductBaseTypeItem: + """Create product base type item from data. + + Args: + product_base_type_data (ProductBaseTypeDict): Product base type data. + + Returns: + ProductBaseTypeDict: Product base type item. + + """ + icon = { + "type": "awesome-font", + "name": "fa.folder", + "color": "#0091B2", + } + return ProductBaseTypeItem( + name=product_base_type_data["name"], + icon=icon) + + +def create_default_product_type_item(product_type: str) -> ProductTypeItem: icon = { "type": "awesome-font", "name": "fa.folder", @@ -136,10 +185,28 @@ def create_default_product_type_item(product_type): return ProductTypeItem(product_type, icon) +def create_default_product_base_type_item( + product_base_type: str) -> ProductBaseTypeItem: + """Create default product base type item. + + Args: + product_base_type (str): Product base type name. + + Returns: + ProductBaseTypeItem: Default product base type item. + """ + icon = { + "type": "awesome-font", + "name": "fa.folder", + "color": "#0091B2", + } + return ProductBaseTypeItem(product_base_type, icon) + + class ProductsModel: """Model for products, version and representation. - All of the entities are product based. This model prepares data for UI + All the entities are product based. This model prepares data for UI and caches it for faster access. Note: @@ -161,6 +228,8 @@ class ProductsModel: # Cache helpers self._product_type_items_cache = NestedCacheItem( levels=1, default_factory=list, lifetime=self.lifetime) + self._product_base_type_items_cache = NestedCacheItem( + levels=1, default_factory=list, lifetime=self.lifetime) self._product_items_cache = NestedCacheItem( levels=2, default_factory=dict, lifetime=self.lifetime) self._repre_items_cache = NestedCacheItem( @@ -199,6 +268,36 @@ class ProductsModel: ]) return cache.get_data() + def get_product_base_type_items( + self, + project_name: Optional[str]) -> list[ProductBaseTypeItem]: + """Product base type items for the project. + + Args: + project_name (optional, str): Project name. + + Returns: + list[ProductBaseTypeDict]: Product base type items. + + """ + if not project_name: + return [] + + cache = self._product_base_type_items_cache[project_name] + if not cache.is_valid: + product_base_types = [] + # TODO add temp implementation here when it is actually + # implemented and available on server. + if hasattr(ayon_api, "get_project_product_base_types"): + product_base_types = ayon_api.get_project_product_base_types( + project_name + ) + cache.update_data([ + product_base_type_item_from_data(product_base_type) + for product_base_type in product_base_types + ]) + return cache.get_data() + def get_product_items(self, project_name, folder_ids, sender): """Product items with versions for project and folder ids. @@ -449,11 +548,12 @@ class ProductsModel: def _create_product_items( self, - project_name, - products, - versions, + project_name: str, + products: Iterable[ProductDict], + versions: Iterable[VersionDict], folder_items=None, product_type_items=None, + product_base_type_items: Optional[Iterable[ProductBaseTypeItem]] = None ): if folder_items is None: folder_items = self._controller.get_folder_items(project_name) @@ -461,6 +561,11 @@ class ProductsModel: if product_type_items is None: product_type_items = self.get_product_type_items(project_name) + if product_base_type_items is None: + product_base_type_items = self.get_product_base_type_items( + project_name + ) + loaded_product_ids = self._controller.get_loaded_product_ids() versions_by_product_id = collections.defaultdict(list) @@ -470,7 +575,13 @@ class ProductsModel: product_type_item.name: product_type_item for product_type_item in product_type_items } - output = {} + + product_base_type_items_by_name: dict[str, ProductBaseTypeItem] = { + product_base_type_item.name: product_base_type_item + for product_base_type_item in product_base_type_items + } + + output: dict[str, ProductItem] = {} for product in products: product_id = product["id"] folder_id = product["folderId"] @@ -484,6 +595,7 @@ class ProductsModel: product, versions, product_type_items_by_name, + product_base_type_items_by_name, folder_item.label, product_id in loaded_product_ids, ) diff --git a/client/ayon_core/tools/loader/ui/product_types_combo.py b/client/ayon_core/tools/loader/ui/product_types_combo.py deleted file mode 100644 index 525f1cae1b..0000000000 --- a/client/ayon_core/tools/loader/ui/product_types_combo.py +++ /dev/null @@ -1,170 +0,0 @@ -from __future__ import annotations -from qtpy import QtGui, QtCore - -from ._multicombobox import ( - CustomPaintMultiselectComboBox, - BaseQtModel, -) - -STATUS_ITEM_TYPE = 0 -SELECT_ALL_TYPE = 1 -DESELECT_ALL_TYPE = 2 -SWAP_STATE_TYPE = 3 - -PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 1 -ITEM_TYPE_ROLE = QtCore.Qt.UserRole + 2 -ITEM_SUBTYPE_ROLE = QtCore.Qt.UserRole + 3 - - -class ProductTypesQtModel(BaseQtModel): - refreshed = QtCore.Signal() - - def __init__(self, controller): - self._reset_filters_on_refresh = True - self._refreshing = False - self._bulk_change = False - self._items_by_name = {} - - super().__init__( - item_type_role=ITEM_TYPE_ROLE, - item_subtype_role=ITEM_SUBTYPE_ROLE, - empty_values_label="No product types...", - controller=controller, - ) - - def is_refreshing(self): - return self._refreshing - - def refresh(self, project_name): - self._refreshing = True - super().refresh(project_name) - - self._reset_filters_on_refresh = False - self._refreshing = False - self.refreshed.emit() - - def reset_product_types_filter_on_refresh(self): - self._reset_filters_on_refresh = True - - def _get_standard_items(self) -> list[QtGui.QStandardItem]: - return list(self._items_by_name.values()) - - def _clear_standard_items(self): - self._items_by_name.clear() - - def _prepare_new_value_items(self, project_name: str, _: bool) -> tuple[ - list[QtGui.QStandardItem], list[QtGui.QStandardItem] - ]: - product_type_items = self._controller.get_product_type_items( - project_name) - self._last_project = project_name - - names_to_remove = set(self._items_by_name.keys()) - items = [] - items_filter_required = {} - for product_type_item in product_type_items: - name = product_type_item.name - names_to_remove.discard(name) - item = self._items_by_name.get(name) - # Apply filter to new items or if filters reset is requested - filter_required = self._reset_filters_on_refresh - if item is None: - filter_required = True - item = QtGui.QStandardItem(name) - item.setData(name, PRODUCT_TYPE_ROLE) - item.setEditable(False) - item.setCheckable(True) - self._items_by_name[name] = item - - items.append(item) - - if filter_required: - items_filter_required[name] = item - - if items_filter_required: - product_types_filter = self._controller.get_product_types_filter() - for product_type, item in items_filter_required.items(): - matching = ( - int(product_type in product_types_filter.product_types) - + int(product_types_filter.is_allow_list) - ) - item.setCheckState( - QtCore.Qt.Checked - if matching % 2 == 0 - else QtCore.Qt.Unchecked - ) - - items_to_remove = [] - for name in names_to_remove: - items_to_remove.append( - self._items_by_name.pop(name) - ) - - # Uncheck all if all are checked (same result) - if all( - item.checkState() == QtCore.Qt.Checked - for item in items - ): - for item in items: - item.setCheckState(QtCore.Qt.Unchecked) - - return items, items_to_remove - - -class ProductTypesCombobox(CustomPaintMultiselectComboBox): - def __init__(self, controller, parent): - self._controller = controller - model = ProductTypesQtModel(controller) - super().__init__( - PRODUCT_TYPE_ROLE, - PRODUCT_TYPE_ROLE, - QtCore.Qt.ForegroundRole, - QtCore.Qt.DecorationRole, - item_type_role=ITEM_TYPE_ROLE, - model=model, - parent=parent - ) - - model.refreshed.connect(self._on_model_refresh) - - self.set_placeholder_text("Product types filter...") - self._model = model - self._last_project_name = None - self._fully_disabled_filter = False - - controller.register_event_callback( - "selection.project.changed", - self._on_project_change - ) - controller.register_event_callback( - "projects.refresh.finished", - self._on_projects_refresh - ) - self.setToolTip("Product types filter") - self.value_changed.connect( - self._on_product_type_filter_change - ) - - def reset_product_types_filter_on_refresh(self): - self._model.reset_product_types_filter_on_refresh() - - def _on_model_refresh(self): - self.value_changed.emit() - - def _on_product_type_filter_change(self): - lines = ["Product types filter"] - for item in self.get_value_info(): - status_name, enabled = item - lines.append(f"{'✔' if enabled else '☐'} {status_name}") - - self.setToolTip("\n".join(lines)) - - def _on_project_change(self, event): - project_name = event["project_name"] - self._last_project_name = project_name - self._model.refresh(project_name) - - def _on_projects_refresh(self): - if self._last_project_name: - self._model.refresh(self._last_project_name) - self._on_product_type_filter_change() diff --git a/client/ayon_core/tools/loader/ui/products_delegates.py b/client/ayon_core/tools/loader/ui/products_delegates.py index 8cece4687f..e78b32ceb1 100644 --- a/client/ayon_core/tools/loader/ui/products_delegates.py +++ b/client/ayon_core/tools/loader/ui/products_delegates.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import numbers import uuid from typing import Dict @@ -18,16 +20,19 @@ from .products_model import ( SYNC_REMOTE_SITE_AVAILABILITY, ) -STATUS_NAME_ROLE = QtCore.Qt.UserRole + 1 -TASK_ID_ROLE = QtCore.Qt.UserRole + 2 +COMBO_VERSION_ID_ROLE = QtCore.Qt.UserRole + 1 +COMBO_TASK_ID_ROLE = QtCore.Qt.UserRole + 2 +COMBO_STATUS_NAME_ROLE = QtCore.Qt.UserRole + 3 +COMBO_VERSION_TAGS_ROLE = QtCore.Qt.UserRole + 4 +COMBO_TASK_TAGS_ROLE = QtCore.Qt.UserRole + 5 -class VersionsModel(QtGui.QStandardItemModel): +class ComboVersionsModel(QtGui.QStandardItemModel): def __init__(self): super().__init__() self._items_by_id = {} - def update_versions(self, version_items): + def update_versions(self, version_items, task_tags_by_version_id): version_ids = { version_item.version_id for version_item in version_items @@ -39,6 +44,7 @@ class VersionsModel(QtGui.QStandardItemModel): item = self._items_by_id.pop(item_id) root_item.removeRow(item.row()) + version_tags_by_version_id = {} for idx, version_item in enumerate(version_items): version_id = version_item.version_id @@ -48,34 +54,74 @@ class VersionsModel(QtGui.QStandardItemModel): item = QtGui.QStandardItem(label) item.setData(version_id, QtCore.Qt.UserRole) self._items_by_id[version_id] = item - item.setData(version_item.status, STATUS_NAME_ROLE) - item.setData(version_item.task_id, TASK_ID_ROLE) + version_tags = set(version_item.tags) + task_tags = task_tags_by_version_id[version_id] + item.setData(version_id, COMBO_VERSION_ID_ROLE) + item.setData(version_item.status, COMBO_STATUS_NAME_ROLE) + item.setData(version_item.task_id, COMBO_TASK_ID_ROLE) + item.setData("|".join(version_tags), COMBO_VERSION_TAGS_ROLE) + item.setData("|".join(task_tags), COMBO_TASK_TAGS_ROLE) + version_tags_by_version_id[version_id] = set(version_item.tags) if item.row() != idx: root_item.insertRow(idx, item) -class VersionsFilterModel(QtCore.QSortFilterProxyModel): +class ComboVersionsFilterModel(QtCore.QSortFilterProxyModel): def __init__(self): super().__init__() self._status_filter = None self._task_ids_filter = None + self._version_tags_filter = None + self._task_tags_filter = None def filterAcceptsRow(self, row, parent): + index = None if self._status_filter is not None: if not self._status_filter: return False - - index = self.sourceModel().index(row, 0, parent) - status = index.data(STATUS_NAME_ROLE) + if index is None: + index = self.sourceModel().index(row, 0, parent) + status = index.data(COMBO_STATUS_NAME_ROLE) if status not in self._status_filter: return False if self._task_ids_filter: - index = self.sourceModel().index(row, 0, parent) - task_id = index.data(TASK_ID_ROLE) + if index is None: + index = self.sourceModel().index(row, 0, parent) + task_id = index.data(COMBO_TASK_ID_ROLE) if task_id not in self._task_ids_filter: return False + + if self._version_tags_filter is not None: + if not self._version_tags_filter: + return False + + if index is None: + model = self.sourceModel() + index = model.index(row, 0, parent) + version_tags_s = index.data(COMBO_TASK_TAGS_ROLE) + version_tags = set() + if version_tags_s: + version_tags = set(version_tags_s.split("|")) + + if not version_tags & self._version_tags_filter: + return False + + if self._task_tags_filter is not None: + if not self._task_tags_filter: + return False + + if index is None: + model = self.sourceModel() + index = model.index(row, 0, parent) + task_tags_s = index.data(COMBO_TASK_TAGS_ROLE) + task_tags = set() + if task_tags_s: + task_tags = set(task_tags_s.split("|")) + if not (task_tags & self._task_tags_filter): + return False + return True def set_tasks_filter(self, task_ids): @@ -84,12 +130,24 @@ class VersionsFilterModel(QtCore.QSortFilterProxyModel): self._task_ids_filter = task_ids self.invalidateFilter() + def set_task_tags_filter(self, tags): + if self._task_tags_filter == tags: + return + self._task_tags_filter = tags + self.invalidateFilter() + def set_statuses_filter(self, status_names): if self._status_filter == status_names: return self._status_filter = status_names self.invalidateFilter() + def set_version_tags_filter(self, tags): + if self._version_tags_filter == tags: + return + self._version_tags_filter = tags + self.invalidateFilter() + class VersionComboBox(QtWidgets.QComboBox): value_changed = QtCore.Signal(str, str) @@ -97,8 +155,8 @@ class VersionComboBox(QtWidgets.QComboBox): def __init__(self, product_id, parent): super().__init__(parent) - versions_model = VersionsModel() - proxy_model = VersionsFilterModel() + versions_model = ComboVersionsModel() + proxy_model = ComboVersionsFilterModel() proxy_model.setSourceModel(versions_model) self.setModel(proxy_model) @@ -123,6 +181,13 @@ class VersionComboBox(QtWidgets.QComboBox): if self.currentIndex() != 0: self.setCurrentIndex(0) + def set_task_tags_filter(self, tags): + self._proxy_model.set_task_tags_filter(tags) + if self.count() == 0: + return + if self.currentIndex() != 0: + self.setCurrentIndex(0) + def set_statuses_filter(self, status_names): self._proxy_model.set_statuses_filter(status_names) if self.count() == 0: @@ -130,12 +195,24 @@ class VersionComboBox(QtWidgets.QComboBox): if self.currentIndex() != 0: self.setCurrentIndex(0) + def set_version_tags_filter(self, tags): + self._proxy_model.set_version_tags_filter(tags) + if self.count() == 0: + return + if self.currentIndex() != 0: + self.setCurrentIndex(0) + def all_versions_filtered_out(self): if self._items_by_id: return self.count() == 0 return False - def update_versions(self, version_items, current_version_id): + def update_versions( + self, + version_items, + current_version_id, + task_tags_by_version_id, + ): self.blockSignals(True) version_items = list(version_items) version_ids = [ @@ -146,7 +223,9 @@ class VersionComboBox(QtWidgets.QComboBox): current_version_id = version_ids[0] self._current_id = current_version_id - self._versions_model.update_versions(version_items) + self._versions_model.update_versions( + version_items, task_tags_by_version_id + ) index = version_ids.index(current_version_id) if self.currentIndex() != index: @@ -173,6 +252,8 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): self._editor_by_id: Dict[str, VersionComboBox] = {} self._task_ids_filter = None self._statuses_filter = None + self._version_tags_filter = None + self._task_tags_filter = None def displayText(self, value, locale): if not isinstance(value, numbers.Integral): @@ -185,10 +266,26 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): widget.set_tasks_filter(task_ids) def set_statuses_filter(self, status_names): - self._statuses_filter = set(status_names) + if status_names is not None: + status_names = set(status_names) + self._statuses_filter = status_names for widget in self._editor_by_id.values(): widget.set_statuses_filter(status_names) + def set_version_tags_filter(self, tags): + if tags is not None: + tags = set(tags) + self._version_tags_filter = tags + for widget in self._editor_by_id.values(): + widget.set_version_tags_filter(tags) + + def set_task_tags_filter(self, tags): + if tags is not None: + tags = set(tags) + self._task_tags_filter = tags + for widget in self._editor_by_id.values(): + widget.set_task_tags_filter(tags) + def paint(self, painter, option, index): fg_color = index.data(QtCore.Qt.ForegroundRole) if fg_color: @@ -200,7 +297,7 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): fg_color = None if not fg_color: - return super(VersionDelegate, self).paint(painter, option, index) + return super().paint(painter, option, index) if option.widget: style = option.widget.style() @@ -263,11 +360,22 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): editor.clear() # Current value of the index - versions = index.data(VERSION_NAME_EDIT_ROLE) or [] + product_id = index.data(PRODUCT_ID_ROLE) version_id = index.data(VERSION_ID_ROLE) + model = index.model() + while hasattr(model, "sourceModel"): + model = model.sourceModel() + versions = model.get_version_items_by_product_id(product_id) + task_tags_by_version_id = { + version_item.version_id: model.get_task_tags_by_id( + version_item.task_id + ) + for version_item in versions + } - editor.update_versions(versions, version_id) + editor.update_versions(versions, version_id, task_tags_by_version_id) editor.set_tasks_filter(self._task_ids_filter) + editor.set_task_tags_filter(self._task_tags_filter) editor.set_statuses_filter(self._statuses_filter) def setModelData(self, editor, model, index): diff --git a/client/ayon_core/tools/loader/ui/products_model.py b/client/ayon_core/tools/loader/ui/products_model.py index cebae9bca7..f3e5271f51 100644 --- a/client/ayon_core/tools/loader/ui/products_model.py +++ b/client/ayon_core/tools/loader/ui/products_model.py @@ -16,31 +16,34 @@ TASK_ID_ROLE = QtCore.Qt.UserRole + 5 PRODUCT_ID_ROLE = QtCore.Qt.UserRole + 6 PRODUCT_NAME_ROLE = QtCore.Qt.UserRole + 7 PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 8 -PRODUCT_TYPE_ICON_ROLE = QtCore.Qt.UserRole + 9 -PRODUCT_IN_SCENE_ROLE = QtCore.Qt.UserRole + 10 -VERSION_ID_ROLE = QtCore.Qt.UserRole + 11 -VERSION_HERO_ROLE = QtCore.Qt.UserRole + 12 -VERSION_NAME_ROLE = QtCore.Qt.UserRole + 13 -VERSION_NAME_EDIT_ROLE = QtCore.Qt.UserRole + 14 -VERSION_PUBLISH_TIME_ROLE = QtCore.Qt.UserRole + 15 -VERSION_STATUS_NAME_ROLE = QtCore.Qt.UserRole + 16 -VERSION_STATUS_SHORT_ROLE = QtCore.Qt.UserRole + 17 -VERSION_STATUS_COLOR_ROLE = QtCore.Qt.UserRole + 18 -VERSION_STATUS_ICON_ROLE = QtCore.Qt.UserRole + 19 -VERSION_AUTHOR_ROLE = QtCore.Qt.UserRole + 20 -VERSION_FRAME_RANGE_ROLE = QtCore.Qt.UserRole + 21 -VERSION_DURATION_ROLE = QtCore.Qt.UserRole + 22 -VERSION_HANDLES_ROLE = QtCore.Qt.UserRole + 23 -VERSION_STEP_ROLE = QtCore.Qt.UserRole + 24 -VERSION_AVAILABLE_ROLE = QtCore.Qt.UserRole + 25 -VERSION_THUMBNAIL_ID_ROLE = QtCore.Qt.UserRole + 26 -ACTIVE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 27 -REMOTE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 28 -REPRESENTATIONS_COUNT_ROLE = QtCore.Qt.UserRole + 29 -SYNC_ACTIVE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 30 -SYNC_REMOTE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 31 +PRODUCT_BASE_TYPE_ROLE = QtCore.Qt.UserRole + 9 +PRODUCT_TYPE_ICON_ROLE = QtCore.Qt.UserRole + 10 +PRODUCT_IN_SCENE_ROLE = QtCore.Qt.UserRole + 11 +VERSION_ID_ROLE = QtCore.Qt.UserRole + 12 +VERSION_HERO_ROLE = QtCore.Qt.UserRole + 13 +VERSION_NAME_ROLE = QtCore.Qt.UserRole + 14 +VERSION_NAME_EDIT_ROLE = QtCore.Qt.UserRole + 15 +VERSION_PUBLISH_TIME_ROLE = QtCore.Qt.UserRole + 16 +VERSION_STATUS_NAME_ROLE = QtCore.Qt.UserRole + 17 +VERSION_STATUS_SHORT_ROLE = QtCore.Qt.UserRole + 18 +VERSION_STATUS_COLOR_ROLE = QtCore.Qt.UserRole + 19 +VERSION_STATUS_ICON_ROLE = QtCore.Qt.UserRole + 20 +VERSION_AUTHOR_ROLE = QtCore.Qt.UserRole + 21 +VERSION_FRAME_RANGE_ROLE = QtCore.Qt.UserRole + 22 +VERSION_DURATION_ROLE = QtCore.Qt.UserRole + 23 +VERSION_HANDLES_ROLE = QtCore.Qt.UserRole + 24 +VERSION_STEP_ROLE = QtCore.Qt.UserRole + 25 +VERSION_AVAILABLE_ROLE = QtCore.Qt.UserRole + 26 +VERSION_THUMBNAIL_ID_ROLE = QtCore.Qt.UserRole + 27 +ACTIVE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 28 +REMOTE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 29 +REPRESENTATIONS_COUNT_ROLE = QtCore.Qt.UserRole + 30 +SYNC_ACTIVE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 31 +SYNC_REMOTE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 32 -STATUS_NAME_FILTER_ROLE = QtCore.Qt.UserRole + 32 +STATUS_NAME_FILTER_ROLE = QtCore.Qt.UserRole + 33 +TASK_TAGS_FILTER_ROLE = QtCore.Qt.UserRole + 34 +VERSION_TAGS_FILTER_ROLE = QtCore.Qt.UserRole + 35 class ProductsModel(QtGui.QStandardItemModel): @@ -49,6 +52,7 @@ class ProductsModel(QtGui.QStandardItemModel): column_labels = [ "Product name", "Product type", + "Product base type", "Folder", "Version", "Status", @@ -79,6 +83,7 @@ class ProductsModel(QtGui.QStandardItemModel): product_name_col = column_labels.index("Product name") product_type_col = column_labels.index("Product type") + product_base_type_col = column_labels.index("Product base type") folders_label_col = column_labels.index("Folder") version_col = column_labels.index("Version") status_col = column_labels.index("Status") @@ -93,6 +98,7 @@ class ProductsModel(QtGui.QStandardItemModel): _display_role_mapping = { product_name_col: QtCore.Qt.DisplayRole, product_type_col: PRODUCT_TYPE_ROLE, + product_base_type_col: PRODUCT_BASE_TYPE_ROLE, folders_label_col: FOLDER_LABEL_ROLE, version_col: VERSION_NAME_ROLE, status_col: VERSION_STATUS_NAME_ROLE, @@ -130,6 +136,7 @@ class ProductsModel(QtGui.QStandardItemModel): self._last_folder_ids = [] self._last_project_statuses = {} self._last_status_icons_by_name = {} + self._last_task_tags_by_task_id = {} def get_product_item_indexes(self): return [ @@ -170,6 +177,17 @@ class ProductsModel(QtGui.QStandardItemModel): self._last_folder_ids ) + def get_task_tags_by_id(self, task_id): + return self._last_task_tags_by_task_id.get(task_id, set()) + + def get_version_items_by_product_id(self, product_id: str): + product_item = self._product_items_by_id.get(product_id) + if product_item is None: + return None + version_items = list(product_item.version_items.values()) + version_items.sort(reverse=True) + return version_items + def flags(self, index): # Make the version column editable if index.column() == self.version_col and index.data(PRODUCT_ID_ROLE): @@ -224,9 +242,9 @@ class ProductsModel(QtGui.QStandardItemModel): product_item = self._product_items_by_id.get(product_id) if product_item is None: return None - product_items = list(product_item.version_items.values()) - product_items.sort(reverse=True) - return product_items + version_items = list(product_item.version_items.values()) + version_items.sort(reverse=True) + return version_items if role == QtCore.Qt.EditRole: return None @@ -422,6 +440,16 @@ class ProductsModel(QtGui.QStandardItemModel): version_item.status for version_item in product_item.version_items.values() } + version_tags = set() + task_tags = set() + for version_item in product_item.version_items.values(): + version_tags |= set(version_item.tags) + _task_tags = self._last_task_tags_by_task_id.get( + version_item.task_id + ) + if _task_tags: + task_tags |= set(_task_tags) + if model_item is None: product_id = product_item.product_id model_item = QtGui.QStandardItem(product_item.product_name) @@ -432,6 +460,9 @@ class ProductsModel(QtGui.QStandardItemModel): 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_base_type, PRODUCT_BASE_TYPE_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) @@ -440,6 +471,8 @@ class ProductsModel(QtGui.QStandardItemModel): self._items_by_id[product_id] = model_item model_item.setData("|".join(statuses), STATUS_NAME_FILTER_ROLE) + model_item.setData("|".join(version_tags), VERSION_TAGS_FILTER_ROLE) + model_item.setData("|".join(task_tags), TASK_TAGS_FILTER_ROLE) 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) @@ -470,6 +503,14 @@ class ProductsModel(QtGui.QStandardItemModel): } self._last_status_icons_by_name = {} + task_items = self._controller.get_task_items( + project_name, folder_ids, sender=PRODUCTS_MODEL_SENDER_NAME + ) + self._last_task_tags_by_task_id = { + task_item.task_id: task_item.tags + for task_item in task_items + } + active_site_icon_def = self._controller.get_active_site_icon_def( project_name ) @@ -484,6 +525,7 @@ class ProductsModel(QtGui.QStandardItemModel): folder_ids, sender=PRODUCTS_MODEL_SENDER_NAME ) + product_items_by_id = { product_item.product_id: product_item for product_item in product_items diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index 94d95b9026..f1c82ee83d 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -4,6 +4,7 @@ from typing import Optional from qtpy import QtWidgets, QtCore +from ayon_core.pipeline.compatibility import is_product_base_type_supported from ayon_core.tools.utils import ( RecursiveSortFilterProxyModel, DeselectableTreeView, @@ -26,6 +27,8 @@ from .products_model import ( VERSION_STATUS_ICON_ROLE, VERSION_THUMBNAIL_ID_ROLE, STATUS_NAME_FILTER_ROLE, + VERSION_TAGS_FILTER_ROLE, + TASK_TAGS_FILTER_ROLE, ) from .products_delegates import ( VersionDelegate, @@ -41,6 +44,8 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): self._product_type_filters = None self._statuses_filter = None + self._version_tags_filter = None + self._task_tags_filter = None self._task_ids_filter = None self._ascending_sort = True @@ -67,6 +72,18 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): self._statuses_filter = statuses_filter self.invalidateFilter() + def set_version_tags_filter(self, tags): + if self._version_tags_filter == tags: + return + self._version_tags_filter = tags + self.invalidateFilter() + + def set_task_tags_filter(self, tags): + if self._task_tags_filter == tags: + return + self._task_tags_filter = tags + self.invalidateFilter() + def filterAcceptsRow(self, source_row, source_parent): source_model = self.sourceModel() index = source_model.index(source_row, 0, source_parent) @@ -83,6 +100,16 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): ): return False + if not self._accept_row_by_role_value( + index, self._version_tags_filter, VERSION_TAGS_FILTER_ROLE + ): + return False + + if not self._accept_row_by_role_value( + index, self._task_tags_filter, TASK_TAGS_FILTER_ROLE + ): + return False + return super().filterAcceptsRow(source_row, source_parent) def _accept_task_ids_filter(self, index): @@ -102,10 +129,11 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): if not filter_value: return False - status_s = index.data(role) - for status in status_s.split("|"): - if status in filter_value: - return True + value_s = index.data(role) + if value_s: + for value in value_s.split("|"): + if value in filter_value: + return True return False def lessThan(self, left, right): @@ -142,6 +170,7 @@ class ProductsWidget(QtWidgets.QWidget): default_widths = ( 200, # Product name 90, # Product type + 90, # Product base type 130, # Folder label 60, # Version 100, # Status @@ -261,6 +290,12 @@ class ProductsWidget(QtWidgets.QWidget): self._controller.is_sitesync_enabled() ) + if not is_product_base_type_supported(): + # Hide product base type column + products_view.setColumnHidden( + products_model.product_base_type_col, True + ) + def set_name_filter(self, name): """Set filter of product name. @@ -290,6 +325,14 @@ class ProductsWidget(QtWidgets.QWidget): self._version_delegate.set_statuses_filter(status_names) self._products_proxy_model.set_statuses_filter(status_names) + def set_version_tags_filter(self, version_tags): + self._version_delegate.set_version_tags_filter(version_tags) + self._products_proxy_model.set_version_tags_filter(version_tags) + + def set_task_tags_filter(self, task_tags): + self._version_delegate.set_task_tags_filter(task_tags) + self._products_proxy_model.set_task_tags_filter(task_tags) + def set_product_type_filter(self, product_type_filters): """ diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py new file mode 100644 index 0000000000..ab673df1ac --- /dev/null +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -0,0 +1,1122 @@ +import copy +import uuid +from dataclasses import dataclass +from typing import Any, Optional + +from qtpy import QtCore, QtWidgets, QtGui + +from ayon_core.style import get_objected_colors +from ayon_core.tools.utils import ( + get_qt_icon, + SquareButton, + BaseClickableFrame, + PixmapLabel, + SeparatorWidget, +) + + +def set_line_edit_focus( + widget: QtWidgets.QLineEdit, + *, + append_text: Optional[str] = None, + backspace: bool = False, +): + full_text = widget.text() + if backspace and full_text: + full_text = full_text[:-1] + + if append_text: + full_text += append_text + widget.setText(full_text) + widget.setFocus() + widget.setCursorPosition(len(full_text)) + + +@dataclass +class FilterDefinition: + """Search bar definition. + + Attributes: + name (str): Name of the definition. + title (str): Title of the search bar. + icon (str): Icon name for the search bar. + placeholder (str): Placeholder text for the search bar. + + """ + name: str + title: str + filter_type: str + icon: Optional[dict[str, Any]] = None + placeholder: Optional[str] = None + items: Optional[list[dict[str, str]]] = None + + +class CloseButton(SquareButton): + """Close button for search item display widget.""" + _icon = None + _hover_color = None + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.__class__._icon is None: + self.__class__._icon = get_qt_icon({ + "type": "material-symbols", + "name": "close", + "color": "#FFFFFF", + }) + if self.__class__._hover_color is None: + color = get_objected_colors("bg-view-selection-hover") + self.__class__._hover_color = color.get_qcolor() + + self.setIcon(self.__class__._icon) + + def paintEvent(self, event): + """Override paint event to draw a close button.""" + painter = QtWidgets.QStylePainter(self) + painter.setRenderHint(QtGui.QPainter.Antialiasing) + option = QtWidgets.QStyleOptionButton() + self.initStyleOption(option) + icon = self.icon() + size = min(self.width(), self.height()) + rect = QtCore.QRect(0, 0, size, size) + rect.adjust(2, 2, -2, -2) + painter.setPen(QtCore.Qt.NoPen) + bg_color = QtCore.Qt.transparent + if option.state & QtWidgets.QStyle.State_MouseOver: + bg_color = self._hover_color + + painter.setBrush(bg_color) + painter.setClipRect(event.rect()) + painter.drawEllipse(rect) + rect.adjust(2, 2, -2, -2) + icon.paint(painter, rect) + + +class SearchItemDisplayWidget(BaseClickableFrame): + """Widget displaying a set filter in the bar.""" + close_requested = QtCore.Signal(str) + edit_requested = QtCore.Signal(str) + + def __init__( + self, + filter_def: FilterDefinition, + parent: QtWidgets.QWidget, + ): + super().__init__(parent) + + self._filter_def = filter_def + + title_widget = QtWidgets.QLabel(f"{filter_def.title}:", self) + + value_wrapper = QtWidgets.QWidget(self) + value_wrapper.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + value_widget = QtWidgets.QLabel(value_wrapper) + value_widget.setObjectName("ValueWidget") + value_widget.setText("") + value_layout = QtWidgets.QVBoxLayout(value_wrapper) + value_layout.setContentsMargins(2, 2, 2, 2) + value_layout.addWidget(value_widget) + + close_btn = CloseButton(self) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(4, 0, 0, 0) + main_layout.setSpacing(0) + main_layout.addWidget(title_widget, 0) + main_layout.addWidget(value_wrapper, 0) + main_layout.addWidget(close_btn, 0) + + close_btn.clicked.connect(self._on_remove_clicked) + + self._value_wrapper = value_wrapper + self._value_widget = value_widget + self._value = None + + def set_value(self, value: "str | list[str]"): + text = "" + ellide = True + if value is None: + pass + elif isinstance(value, str): + text = value + elif len(value) == 1: + text = value[0] + elif len(value) > 1: + ellide = False + text = f"Items: {len(value)}" + + if ellide and len(text) > 9: + text = text[:9] + "..." + + text = " " + text + " " + + self._value = copy.deepcopy(value) + self._value_widget.setText(text) + + def get_value(self): + return copy.deepcopy(self._value) + + def _on_remove_clicked(self): + self.close_requested.emit(self._filter_def.name) + + def _mouse_release_callback(self): + self.edit_requested.emit(self._filter_def.name) + + +class FilterItemButton(BaseClickableFrame): + filter_requested = QtCore.Signal(str) + + def __init__( + self, + filter_def: FilterDefinition, + parent: QtWidgets.QWidget, + ): + super().__init__(parent) + + self._filter_def = filter_def + + title_widget = QtWidgets.QLabel(filter_def.title, self) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(5, 5, 5, 5) + main_layout.addWidget(title_widget, 1) + + self._title_widget = title_widget + + def _mouse_release_callback(self): + """Handle mouse release event to emit filter request.""" + self.filter_requested.emit(self._filter_def.name) + + +class FiltersPopup(QtWidgets.QWidget): + filter_requested = QtCore.Signal(str) + text_filter_requested = QtCore.Signal(str) + + def __init__(self, parent): + super().__init__(parent) + self.setWindowFlags(QtCore.Qt.Popup | QtCore.Qt.FramelessWindowHint) + self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + + shadow_frame = QtWidgets.QFrame(self) + shadow_frame.setObjectName("ShadowFrame") + + wrapper = QtWidgets.QWidget(self) + wrapper.setObjectName("PopupWrapper") + + wraper_layout = QtWidgets.QVBoxLayout(wrapper) + wraper_layout.setContentsMargins(5, 5, 5, 5) + wraper_layout.setSpacing(5) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(2, 2, 2, 2) + main_layout.addWidget(wrapper) + + shadow_frame.stackUnder(wrapper) + + self._shadow_frame = shadow_frame + self._wrapper = wrapper + self._wrapper_layout = wraper_layout + self._preferred_width = None + + def keyPressEvent(self, event): + if event.key() in (QtCore.Qt.Key_Enter, QtCore.Qt.Key_Return): + event.accept() + self.close() + return + + if event.key() in ( + QtCore.Qt.Key_Backtab, + QtCore.Qt.Key_Backspace, + ): + self.text_filter_requested.emit("") + event.accept() + return + + valid_modifiers = event.modifiers() in ( + QtCore.Qt.NoModifier, + QtCore.Qt.ShiftModifier, + ) + if valid_modifiers and event.key() not in ( + QtCore.Qt.Key_Escape, + QtCore.Qt.Key_Tab, + QtCore.Qt.Key_Return, + ): + text = event.text() + if text: + event.accept() + self.text_filter_requested.emit(text) + return + super().keyPressEvent(event) + + def set_preferred_width(self, width: int): + self._preferred_width = width + + def sizeHint(self): + sh = super().sizeHint() + if self._preferred_width is not None: + sh.setWidth(self._preferred_width) + return sh + + def set_filter_items(self, filter_items): + while self._wrapper_layout.count() > 0: + item = self._wrapper_layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.setVisible(False) + widget.deleteLater() + + for item in filter_items: + widget = FilterItemButton(item, self._wrapper) + widget.filter_requested.connect(self.filter_requested) + self._wrapper_layout.addWidget(widget, 0) + + if self._wrapper_layout.count() == 0: + empty_label = QtWidgets.QLabel( + "No filters available...", self._wrapper + ) + self._wrapper_layout.addWidget(empty_label, 0) + + def showEvent(self, event): + super().showEvent(event) + self._update_shadow() + + def resizeEvent(self, event): + super().resizeEvent(event) + self._update_shadow() + + def _update_shadow(self): + geo = self.geometry() + geo.moveTopLeft(QtCore.QPoint(0, 0)) + self._shadow_frame.setGeometry(geo) + + +class FilterValueItemButton(BaseClickableFrame): + selected = QtCore.Signal(str) + + def __init__(self, widget_id, value, icon, color, parent): + super().__init__(parent) + + title_widget = QtWidgets.QLabel(str(value), self) + if color: + title_widget.setStyleSheet(f"color: {color};") + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(5, 5, 5, 5) + main_layout.addWidget(title_widget, 1) + + self._icon_widget = None + self._title_widget = title_widget + self._main_layout = main_layout + self._selected = False + self._value = value + self._widget_id = widget_id + + if icon: + self.set_icon(icon) + + def set_icon(self, icon: dict[str, Any]): + """Set the icon for the widget.""" + icon = get_qt_icon(icon) + pixmap = icon.pixmap(64, 64) + if self._icon_widget is None: + self._icon_widget = PixmapLabel(pixmap, self) + self._main_layout.insertWidget(0, self._icon_widget, 0) + else: + self._icon_widget.setPixmap(pixmap) + + def get_value(self): + return self._value + + def set_selected(self, selected: bool): + """Set the selection state of the widget.""" + if self._selected == selected: + return + self._selected = selected + self.setProperty("selected", "1" if selected else "") + self.style().polish(self) + + def is_selected(self) -> bool: + return self._selected + + def _mouse_release_callback(self): + """Handle mouse release event to emit filter request.""" + self.selected.emit(self._widget_id) + + +class FilterValueTextInput(QtWidgets.QWidget): + back_requested = QtCore.Signal() + value_changed = QtCore.Signal(str) + close_requested = QtCore.Signal() + + def __init__(self, parent): + super().__init__(parent) + self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + + # Timeout is used to delay the filter focus change on 'showEvent' + # - the focus is changed to something else if is not delayed + filter_timeout = QtCore.QTimer(self) + filter_timeout.setSingleShot(True) + filter_timeout.setInterval(20) + + btns_sep = SeparatorWidget(size=1, parent=self) + btns_widget = QtWidgets.QWidget(self) + btns_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + + text_input = QtWidgets.QLineEdit(self) + + back_btn = QtWidgets.QPushButton("Back", btns_widget) + back_btn.setObjectName("BackButton") + back_btn.setIcon(get_qt_icon({ + "type": "material-symbols", + "name": "arrow_back", + })) + confirm_btn = QtWidgets.QPushButton("Confirm", btns_widget) + confirm_btn.setObjectName("ConfirmButton") + + btns_layout = QtWidgets.QHBoxLayout(btns_widget) + btns_layout.setContentsMargins(0, 0, 0, 0) + btns_layout.addWidget(back_btn, 0) + btns_layout.addStretch(1) + btns_layout.addWidget(confirm_btn, 0) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(text_input, 0) + main_layout.addWidget(btns_sep, 0) + main_layout.addWidget(btns_widget, 0) + + filter_timeout.timeout.connect(self._on_filter_timeout) + text_input.textChanged.connect(self.value_changed) + text_input.returnPressed.connect(self.close_requested) + back_btn.clicked.connect(self.back_requested) + confirm_btn.clicked.connect(self.close_requested) + + self._filter_timeout = filter_timeout + self._text_input = text_input + + def showEvent(self, event): + super().showEvent(event) + + self._filter_timeout.start() + + def get_value(self) -> str: + return self._text_input.text() + + def set_value(self, value: str): + self._text_input.setText(value) + + def set_placeholder_text(self, placeholder_text: str): + self._text_input.setPlaceholderText(placeholder_text) + + def set_text_filter(self, text: str): + kwargs = {} + if text: + kwargs["append_text"] = text + else: + kwargs["backspace"] = True + + set_line_edit_focus(self._text_input, **kwargs) + + def _on_filter_timeout(self): + set_line_edit_focus(self._text_input) + + +class FilterValueItemsView(QtWidgets.QWidget): + value_changed = QtCore.Signal() + close_requested = QtCore.Signal() + back_requested = QtCore.Signal() + + def __init__(self, parent): + super().__init__(parent) + + filter_input = QtWidgets.QLineEdit(self) + filter_input.setPlaceholderText("Filter items...") + + # Timeout is used to delay the filter focus change on 'showEvent' + # - the focus is changed to something else if is not delayed + filter_timeout = QtCore.QTimer(self) + filter_timeout.setSingleShot(True) + filter_timeout.setInterval(20) + + scroll_area = QtWidgets.QScrollArea(self) + scroll_area.setObjectName("ScrollArea") + srcoll_viewport = scroll_area.viewport() + srcoll_viewport.setContentsMargins(0, 0, 0, 0) + scroll_area.setWidgetResizable(True) + scroll_area.setMinimumHeight(20) + scroll_area.setMaximumHeight(400) + + content_widget = QtWidgets.QWidget(scroll_area) + content_widget.setObjectName("ContentWidget") + + content_layout = QtWidgets.QVBoxLayout(content_widget) + content_layout.setContentsMargins(0, 0, 0, 0) + + scroll_area.setWidget(content_widget) + + btns_sep = SeparatorWidget(size=1, parent=self) + btns_widget = QtWidgets.QWidget(self) + btns_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + + back_btn = QtWidgets.QPushButton("Back", btns_widget) + back_btn.setObjectName("BackButton") + back_btn.setIcon(get_qt_icon({ + "type": "material-symbols", + "name": "arrow_back", + })) + + select_all_btn = QtWidgets.QPushButton("Select all", btns_widget) + clear_btn = QtWidgets.QPushButton("Clear", btns_widget) + swap_btn = QtWidgets.QPushButton("Invert", btns_widget) + + confirm_btn = QtWidgets.QPushButton("Confirm", btns_widget) + confirm_btn.setObjectName("ConfirmButton") + confirm_btn.clicked.connect(self.close_requested) + + btns_layout = QtWidgets.QHBoxLayout(btns_widget) + btns_layout.setContentsMargins(0, 0, 0, 0) + btns_layout.addWidget(back_btn, 0) + btns_layout.addStretch(1) + btns_layout.addWidget(select_all_btn, 0) + btns_layout.addWidget(clear_btn, 0) + btns_layout.addWidget(swap_btn, 0) + btns_layout.addStretch(1) + btns_layout.addWidget(confirm_btn, 0) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(filter_input, 0) + main_layout.addWidget(scroll_area) + main_layout.addWidget(btns_sep, 0) + main_layout.addWidget(btns_widget, 0) + + filter_timeout.timeout.connect(self._on_filter_timeout) + filter_input.textChanged.connect(self._on_filter_change) + filter_input.returnPressed.connect(self.close_requested) + back_btn.clicked.connect(self.back_requested) + select_all_btn.clicked.connect(self._on_select_all) + clear_btn.clicked.connect(self._on_clear_selection) + swap_btn.clicked.connect(self._on_swap_selection) + + self._filter_timeout = filter_timeout + self._filter_input = filter_input + self._btns_widget = btns_widget + self._multiselection = False + self._content_layout = content_layout + self._last_selected_widget = None + self._widgets_by_id = {} + + def showEvent(self, event): + super().showEvent(event) + self._filter_timeout.start() + + def keyPressEvent(self, event): + if event.key() in (QtCore.Qt.Key_Enter, QtCore.Qt.Key_Return): + event.accept() + self.close_requested.emit() + return + + if event.key() in ( + QtCore.Qt.Key_Backtab, + QtCore.Qt.Key_Backspace, + ): + event.accept() + set_line_edit_focus(self._filter_input, backspace=True) + return + + valid_modifiers = event.modifiers() in ( + QtCore.Qt.NoModifier, + QtCore.Qt.ShiftModifier, + ) + if valid_modifiers and event.key() not in ( + QtCore.Qt.Key_Escape, + QtCore.Qt.Key_Tab, + QtCore.Qt.Key_Return, + ): + text = event.text() + if text: + event.accept() + set_line_edit_focus(self._filter_input, append_text=text) + return + + super().keyPressEvent(event) + + def set_value(self, value): + current_value = self.get_value() + if self._multiselection: + if value is None: + value = [] + if not isinstance(value, list): + value = [value] + for widget in self._widgets_by_id.values(): + selected = widget.get_value() in value + if selected and self._last_selected_widget is None: + self._last_selected_widget = widget + widget.set_selected(selected) + + if value != current_value: + self.value_changed.emit() + return + + if isinstance(value, list): + if len(value) > 0: + value = value[0] + else: + value = None + + if value is None: + widget = next(iter(self._widgets_by_id.values())) + value = widget.get_value() + + self._last_selected_widget = None + for widget in self._widgets_by_id.values(): + selected = widget.get_value() in value + widget.set_selected(selected) + if selected: + self._last_selected_widget = widget + + if self._last_selected_widget is None: + widget = next(iter(self._widgets_by_id.values())) + self._last_selected_widget = widget + widget.set_selected(True) + + if value != current_value: + self.value_changed.emit() + + def set_multiselection(self, multiselection: bool): + self._multiselection = multiselection + if not self._widgets_by_id or not self._multiselection: + self._btns_widget.setVisible(False) + else: + self._btns_widget.setVisible(True) + + if not self._widgets_by_id or self._multiselection: + return + + value_changed = False + if self._last_selected_widget is None: + value_changed = True + self._last_selected_widget = next( + iter(self._widgets_by_id.values()) + ) + for widget in self._widgets_by_id.values(): + widget.set_selected(widget is self._last_selected_widget) + + if value_changed: + self.value_changed.emit() + + def get_value(self): + """Get the value from the items view.""" + if self._multiselection: + return [ + widget.get_value() + for widget in self._widgets_by_id.values() + if widget.is_selected() + ] + if self._last_selected_widget is not None: + return self._last_selected_widget.get_value() + return None + + def set_items(self, items: list[dict[str, Any]]): + while self._content_layout.count() > 0: + item = self._content_layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.setVisible(False) + widget.deleteLater() + + self._widgets_by_id = {} + self._last_selected_widget = None + # Change filter + self._filter_input.setText("") + + for item in items: + widget_id = uuid.uuid4().hex + widget = FilterValueItemButton( + widget_id, + item["value"], + item.get("icon"), + item.get("color"), + self, + ) + widget.selected.connect(self._on_item_clicked) + self._widgets_by_id[widget_id] = widget + self._content_layout.addWidget(widget, 0) + + if self._content_layout.count() == 0: + empty_label = QtWidgets.QLabel( + "No items to select from...", self + ) + self._btns_widget.setVisible(False) + self._filter_input.setVisible(False) + self._content_layout.addWidget(empty_label, 0) + else: + self._filter_input.setVisible(True) + self._btns_widget.setVisible(self._multiselection) + self._content_layout.addStretch(1) + + def _on_filter_timeout(self): + self._filter_input.setFocus() + + def _on_filter_change(self, text): + text = text.lower() + for widget in self._widgets_by_id.values(): + visible = not text or text in widget.get_value().lower() + widget.setVisible(visible) + + def _on_select_all(self): + changed = False + for widget in self._widgets_by_id.values(): + if not widget.is_selected(): + changed = True + widget.set_selected(True) + if self._last_selected_widget is None: + self._last_selected_widget = widget + + if changed: + self.value_changed.emit() + + def _on_swap_selection(self): + self._last_selected_widget = None + for widget in self._widgets_by_id.values(): + selected = not widget.is_selected() + widget.set_selected(selected) + if selected and self._last_selected_widget is None: + self._last_selected_widget = widget + + self.value_changed.emit() + + def _on_clear_selection(self): + self._last_selected_widget = None + changed = False + for widget in self._widgets_by_id.values(): + if widget.is_selected(): + changed = True + widget.set_selected(False) + + if changed: + self.value_changed.emit() + + def _on_item_clicked(self, widget_id): + widget = self._widgets_by_id.get(widget_id) + if widget is None: + return + + previous_widget = self._last_selected_widget + self._last_selected_widget = widget + if self._multiselection: + widget.set_selected(not widget.is_selected()) + else: + widget.set_selected(True) + if previous_widget is not None: + previous_widget.set_selected(False) + self.value_changed.emit() + + +class FilterValuePopup(QtWidgets.QWidget): + value_changed = QtCore.Signal(str) + closed = QtCore.Signal(str) + back_requested = QtCore.Signal(str) + + def __init__(self, parent): + super().__init__(parent) + self.setWindowFlags(QtCore.Qt.Popup | QtCore.Qt.FramelessWindowHint) + self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + + shadow_frame = QtWidgets.QFrame(self) + shadow_frame.setObjectName("ShadowFrame") + + wrapper = QtWidgets.QWidget(self) + wrapper.setObjectName("PopupWrapper") + + text_input = FilterValueTextInput(wrapper) + text_input.setVisible(False) + + items_view = FilterValueItemsView(wrapper) + items_view.setVisible(False) + + wraper_layout = QtWidgets.QVBoxLayout(wrapper) + wraper_layout.setContentsMargins(5, 5, 5, 5) + wraper_layout.setSpacing(5) + wraper_layout.addWidget(text_input, 0) + wraper_layout.addWidget(items_view, 0) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(2, 2, 2, 2) + main_layout.addWidget(wrapper) + + text_input.value_changed.connect(self._text_changed) + text_input.close_requested.connect(self._close_requested) + text_input.back_requested.connect(self._back_requested) + + items_view.value_changed.connect(self._selection_changed) + items_view.close_requested.connect(self._close_requested) + items_view.back_requested.connect(self._back_requested) + + shadow_frame.stackUnder(wrapper) + + self._shadow_frame = shadow_frame + self._wrapper = wrapper + self._wrapper_layout = wraper_layout + self._text_input = text_input + self._items_view = items_view + + self._active_widget = None + self._filter_name = None + self._preferred_width = None + + def set_preferred_width(self, width: int): + self._preferred_width = width + + def sizeHint(self): + sh = super().sizeHint() + if self._preferred_width is not None: + sh.setWidth(self._preferred_width) + return sh + + def set_text_filter(self, text: str): + if self._active_widget is None: + return + + if self._active_widget is self._text_input: + self._active_widget.set_text_filter(text) + + def set_filter_item( + self, + filter_def: FilterDefinition, + value, + ): + self._text_input.setVisible(False) + self._items_view.setVisible(False) + self._filter_name = filter_def.name + self._active_widget = None + if filter_def.filter_type == "text": + if filter_def.items: + if value is None: + value = filter_def.items[0]["value"] + self._active_widget = self._items_view + self._items_view.set_items(filter_def.items) + self._items_view.set_multiselection(False) + self._items_view.set_value(value) + else: + if value is None: + value = "" + self._text_input.set_placeholder_text( + filter_def.placeholder or "" + ) + self._text_input.set_value(value) + self._active_widget = self._text_input + + elif filter_def.filter_type == "list": + if value is None: + value = [] + self._items_view.set_items(filter_def.items) + self._items_view.set_multiselection(True) + self._items_view.set_value(value) + self._active_widget = self._items_view + + if self._active_widget is not None: + self._active_widget.setVisible(True) + + def showEvent(self, event): + super().showEvent(event) + if self._active_widget is not None: + self._active_widget.setFocus() + self._update_shadow() + + def closeEvent(self, event): + super().closeEvent(event) + self.closed.emit(self._filter_name) + + def hideEvent(self, event): + super().hideEvent(event) + self.closed.emit(self._filter_name) + + def resizeEvent(self, event): + super().resizeEvent(event) + self._update_shadow() + + def _update_shadow(self): + geo = self.geometry() + geo.moveTopLeft(QtCore.QPoint(0, 0)) + self._shadow_frame.setGeometry(geo) + + def get_value(self): + """Get the value from the active widget.""" + if self._active_widget is self._text_input: + return self._text_input.get_value() + elif self._active_widget is self._items_view: + return self._active_widget.get_value() + return None + + def _text_changed(self): + """Handle text change in the text input.""" + if self._active_widget is self._text_input: + # Emit value changed signal if text input is active + self.value_changed.emit(self._filter_name) + + def _selection_changed(self): + self.value_changed.emit(self._filter_name) + + def _close_requested(self): + self.close() + + def _back_requested(self): + self.back_requested.emit(self._filter_name) + self.close() + + +class FiltersBar(BaseClickableFrame): + filter_changed = QtCore.Signal(str) + + def __init__(self, parent): + super().__init__(parent) + + search_icon = get_qt_icon({ + "type": "material-symbols", + "name": "search", + "color": "#FFFFFF", + }) + search_btn = SquareButton(self) + search_btn.setIcon(search_icon) + search_btn.setFlat(True) + search_btn.setObjectName("SearchButton") + + # Wrapper is used to avoid squashing filters + # - the filters are positioned manually without layout + filters_wrap = QtWidgets.QWidget(self) + filters_wrap.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + + # Widget where set filters are displayed + filters_widget = QtWidgets.QWidget(filters_wrap) + filters_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + filters_layout = QtWidgets.QHBoxLayout(filters_widget) + filters_layout.setContentsMargins(0, 0, 0, 0) + filters_layout.addStretch(1) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(4, 4, 4, 4) + main_layout.setSpacing(5) + main_layout.addWidget(search_btn, 0) + main_layout.addWidget(filters_wrap, 1) + + filters_popup = FiltersPopup(self) + filter_value_popup = FilterValuePopup(self) + + search_btn.clicked.connect(self._on_filters_request) + filters_popup.text_filter_requested.connect( + self._on_text_filter_request + ) + + self._search_btn = search_btn + self._filters_wrap = filters_wrap + self._filters_widget = filters_widget + self._filters_layout = filters_layout + self._widgets_by_name = {} + self._filter_defs_by_name = {} + self._filters_popup = filters_popup + self._filter_value_popup = filter_value_popup + + def showEvent(self, event): + super().showEvent(event) + self._update_filters_geo() + + def resizeEvent(self, event): + super().resizeEvent(event) + self._update_filters_geo() + + def wheelEvent(self, event): + scroll_speed = 15 + diff = event.angleDelta().y() / 120.0 + pos_x = self._filters_widget.pos().x() + if diff > 0: + pos_x = min(0, pos_x + scroll_speed) + self._filters_widget.move(pos_x, 0) + return + + rect = self._filters_wrap.rect() + size_hint = self._filters_widget.sizeHint() + if size_hint.width() < rect.width(): + return + pos_x = max( + pos_x - scroll_speed, rect.width() - size_hint.width() + ) + self._filters_widget.move(pos_x, 0) + + def show_filters_popup(self): + filter_defs = [ + filter_def + for filter_def in self._filter_defs_by_name.values() + if filter_def.name not in self._widgets_by_name + ] + filters_popup = FiltersPopup(self) + filters_popup.filter_requested.connect(self._on_filter_request) + filters_popup.text_filter_requested.connect( + self._on_text_filter_request + ) + filters_popup.set_filter_items(filter_defs) + filters_popup.set_preferred_width(self.width()) + + old_popup, self._filters_popup = self._filters_popup, filters_popup + + self._filter_value_popup.setVisible(False) + old_popup.setVisible(False) + old_popup.deleteLater() + + self._show_popup(filters_popup) + + def set_search_items(self, filter_defs: list[FilterDefinition]): + self._filter_defs_by_name = { + filter_def.name: filter_def + for filter_def in filter_defs + } + + def get_filter_value(self, name: str) -> Optional[Any]: + """Get the value of a filter by its name.""" + item_widget = self._widgets_by_name.get(name) + if item_widget is not None: + value = item_widget.get_value() + if isinstance(value, list) and len(value) == 0: + return None + return value + return None + + def set_filter_value(self, name: str, value: Any): + """Set the value of a filter by its name.""" + if name not in self._filter_defs_by_name: + return + + item_widget = self._widgets_by_name.get(name) + if item_widget is None: + self.add_item(name) + item_widget = self._widgets_by_name.get(name) + + item_widget.set_value(value) + self.filter_changed.emit(name) + + def add_item(self, name: str): + """Add a new item to the search bar. + + Args: + name (str): Search definition name. + + """ + filter_def = self._filter_defs_by_name.get(name) + if filter_def is None: + return + + item_widget = self._widgets_by_name.get(name) + if item_widget is not None: + return + + item_widget = SearchItemDisplayWidget( + filter_def, + parent=self._filters_widget, + ) + item_widget.edit_requested.connect(self._on_filter_request) + item_widget.close_requested.connect(self._on_item_close_requested) + self._widgets_by_name[name] = item_widget + idx = self._filters_layout.count() - 1 + self._filters_layout.insertWidget(idx, item_widget, 0) + + def _update_filters_geo(self): + geo = self._filters_wrap.geometry() + geo.moveTopLeft(QtCore.QPoint(0, 0)) + # Arbitrary width + geo.setWidth(3000) + + self._filters_widget.setGeometry(geo) + + def _reposition_filters_widget(self): + rect = self._filters_wrap.rect() + size_hint = self._filters_widget.sizeHint() + if size_hint.width() < rect.width(): + self._filters_widget.move(0, 0) + return + pos_x = self._filters_widget.pos().x() + pos_x = max( + pos_x, rect.width() - size_hint.width() + ) + self._filters_widget.move(pos_x, 0) + + def _mouse_release_callback(self): + self.show_filters_popup() + + def _on_filters_request(self): + self.show_filters_popup() + + def _on_text_filter_request(self, text: str): + if "product_name" not in self._filter_defs_by_name: + return + + self._on_filter_request("product_name") + self._filter_value_popup.set_text_filter(text) + + def _on_filter_request(self, filter_name: str): + """Handle filter request from the popup.""" + self.add_item(filter_name) + self._filters_popup.hide() + filter_def = self._filter_defs_by_name.get(filter_name) + widget = self._widgets_by_name.get(filter_name) + value = None + if widget is not None: + value = widget.get_value() + + filter_value_popup = FilterValuePopup(self) + filter_value_popup.set_preferred_width(self.width()) + filter_value_popup.set_filter_item(filter_def, value) + filter_value_popup.value_changed.connect(self._on_filter_value_change) + filter_value_popup.closed.connect(self._on_filter_value_closed) + filter_value_popup.back_requested.connect(self._on_filter_value_back) + + old_popup, self._filter_value_popup = ( + self._filter_value_popup, filter_value_popup + ) + + old_popup.setVisible(False) + old_popup.deleteLater() + + self._filters_popup.setVisible(False) + + self._show_popup(filter_value_popup) + self._on_filter_value_change(filter_def.name) + + def _show_popup(self, popup: QtWidgets.QWidget): + """Show a popup widget.""" + geo = self.geometry() + bl_pos_g = self.mapToGlobal(QtCore.QPoint(0, geo.height() + 5)) + popup.show() + popup.move(bl_pos_g.x(), bl_pos_g.y()) + popup.raise_() + + def _on_filter_value_change(self, name): + value = self._filter_value_popup.get_value() + item_widget = self._widgets_by_name.get(name) + item_widget.set_value(value) + self.filter_changed.emit(name) + + def _on_filter_value_closed(self, name): + widget = self._widgets_by_name.get(name) + if widget is None: + return + + value = widget.get_value() + if not value: + self._on_item_close_requested(name) + + def _on_filter_value_back(self, name): + self._on_filter_value_closed(name) + self.show_filters_popup() + + def _on_item_close_requested(self, name): + widget = self._widgets_by_name.pop(name, None) + if widget is None: + return + idx = self._filters_layout.indexOf(widget) + if idx > -1: + self._filters_layout.takeAt(idx) + widget.setVisible(False) + widget.deleteLater() + self.filter_changed.emit(name) + + self._reposition_filters_widget() diff --git a/client/ayon_core/tools/loader/ui/statuses_combo.py b/client/ayon_core/tools/loader/ui/statuses_combo.py deleted file mode 100644 index 2f034d00de..0000000000 --- a/client/ayon_core/tools/loader/ui/statuses_combo.py +++ /dev/null @@ -1,157 +0,0 @@ -from __future__ import annotations - -from qtpy import QtCore, QtGui - -from ayon_core.tools.utils import get_qt_icon -from ayon_core.tools.common_models import StatusItem - -from ._multicombobox import ( - CustomPaintMultiselectComboBox, - BaseQtModel, -) - -STATUS_ITEM_TYPE = 0 -SELECT_ALL_TYPE = 1 -DESELECT_ALL_TYPE = 2 -SWAP_STATE_TYPE = 3 - -STATUSES_FILTER_SENDER = "loader.statuses_filter" -STATUS_NAME_ROLE = QtCore.Qt.UserRole + 1 -STATUS_SHORT_ROLE = QtCore.Qt.UserRole + 2 -STATUS_COLOR_ROLE = QtCore.Qt.UserRole + 3 -STATUS_ICON_ROLE = QtCore.Qt.UserRole + 4 -ITEM_TYPE_ROLE = QtCore.Qt.UserRole + 5 -ITEM_SUBTYPE_ROLE = QtCore.Qt.UserRole + 6 - - -class StatusesQtModel(BaseQtModel): - def __init__(self, controller): - self._items_by_name: dict[str, QtGui.QStandardItem] = {} - self._icons_by_name_n_color: dict[str, QtGui.QIcon] = {} - super().__init__( - ITEM_TYPE_ROLE, - ITEM_SUBTYPE_ROLE, - "No statuses...", - controller, - ) - - def _get_standard_items(self) -> list[QtGui.QStandardItem]: - return list(self._items_by_name.values()) - - def _clear_standard_items(self): - self._items_by_name.clear() - - def _prepare_new_value_items( - self, project_name: str, project_changed: bool - ): - status_items: list[StatusItem] = ( - self._controller.get_project_status_items( - project_name, sender=STATUSES_FILTER_SENDER - ) - ) - items = [] - items_to_remove = [] - if not status_items: - return items, items_to_remove - - names_to_remove = set(self._items_by_name) - for row_idx, status_item in enumerate(status_items): - name = status_item.name - if name in self._items_by_name: - item = self._items_by_name[name] - names_to_remove.discard(name) - else: - item = QtGui.QStandardItem() - item.setData(ITEM_SUBTYPE_ROLE, STATUS_ITEM_TYPE) - item.setCheckState(QtCore.Qt.Unchecked) - item.setFlags( - QtCore.Qt.ItemIsEnabled - | QtCore.Qt.ItemIsSelectable - | QtCore.Qt.ItemIsUserCheckable - ) - self._items_by_name[name] = item - - icon = self._get_icon(status_item) - for role, value in ( - (STATUS_NAME_ROLE, status_item.name), - (STATUS_SHORT_ROLE, status_item.short), - (STATUS_COLOR_ROLE, status_item.color), - (STATUS_ICON_ROLE, icon), - ): - if item.data(role) != value: - item.setData(value, role) - - if project_changed: - item.setCheckState(QtCore.Qt.Unchecked) - items.append(item) - - for name in names_to_remove: - items_to_remove.append(self._items_by_name.pop(name)) - - return items, items_to_remove - - def _get_icon(self, status_item: StatusItem) -> QtGui.QIcon: - name = status_item.name - color = status_item.color - unique_id = "|".join([name or "", color or ""]) - icon = self._icons_by_name_n_color.get(unique_id) - if icon is not None: - return icon - - icon: QtGui.QIcon = get_qt_icon({ - "type": "material-symbols", - "name": status_item.icon, - "color": status_item.color - }) - self._icons_by_name_n_color[unique_id] = icon - return icon - - -class StatusesCombobox(CustomPaintMultiselectComboBox): - def __init__(self, controller, parent): - self._controller = controller - model = StatusesQtModel(controller) - super().__init__( - STATUS_NAME_ROLE, - STATUS_SHORT_ROLE, - STATUS_COLOR_ROLE, - STATUS_ICON_ROLE, - item_type_role=ITEM_TYPE_ROLE, - model=model, - parent=parent - ) - self.set_placeholder_text("Version status filter...") - self._model = model - self._last_project_name = None - self._fully_disabled_filter = False - - controller.register_event_callback( - "selection.project.changed", - self._on_project_change - ) - controller.register_event_callback( - "projects.refresh.finished", - self._on_projects_refresh - ) - self.setToolTip("Statuses filter") - self.value_changed.connect( - self._on_status_filter_change - ) - - def _on_status_filter_change(self): - lines = ["Statuses filter"] - for item in self.get_value_info(): - status_name, enabled = item - lines.append(f"{'✔' if enabled else '☐'} {status_name}") - - self.setToolTip("\n".join(lines)) - - def _on_project_change(self, event): - project_name = event["project_name"] - self._last_project_name = project_name - self._model.refresh(project_name) - - def _on_projects_refresh(self): - if self._last_project_name: - self._model.refresh(self._last_project_name) - self._on_status_filter_change() diff --git a/client/ayon_core/tools/loader/ui/tasks_widget.py b/client/ayon_core/tools/loader/ui/tasks_widget.py index 5779fc2a01..cc7e2e9c95 100644 --- a/client/ayon_core/tools/loader/ui/tasks_widget.py +++ b/client/ayon_core/tools/loader/ui/tasks_widget.py @@ -332,10 +332,6 @@ class LoaderTasksWidget(QtWidgets.QWidget): "selection.folders.changed", self._on_folders_selection_changed, ) - controller.register_event_callback( - "tasks.refresh.finished", - self._on_tasks_refresh_finished - ) selection_model = tasks_view.selectionModel() selection_model.selectionChanged.connect(self._on_selection_change) @@ -373,10 +369,6 @@ class LoaderTasksWidget(QtWidgets.QWidget): def _clear(self): self._tasks_model.clear() - def _on_tasks_refresh_finished(self, event): - if event["sender"] != TASKS_MODEL_SENDER_NAME: - self._set_project_name(event["project_name"]) - def _on_folders_selection_changed(self, event): project_name = event["project_name"] folder_ids = event["folder_ids"] diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index b70f5554c7..d056b62b13 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -11,16 +11,24 @@ from ayon_core.tools.utils import ( ) from ayon_core.tools.utils.lib import center_window from ayon_core.tools.utils import ProjectsCombobox +from ayon_core.tools.common_models import StatusItem +from ayon_core.tools.loader.abstract import ProductTypeItem from ayon_core.tools.loader.control import LoaderController from .folders_widget import LoaderFoldersWidget from .tasks_widget import LoaderTasksWidget from .products_widget import ProductsWidget -from .product_types_combo import ProductTypesCombobox from .product_group_dialog import ProductGroupDialog from .info_widget import InfoWidget from .repres_widget import RepresentationsWidget -from .statuses_combo import StatusesCombobox +from .search_bar import FiltersBar, FilterDefinition + +FIND_KEY_SEQUENCE = QtGui.QKeySequence( + QtCore.Qt.Modifier.CTRL | QtCore.Qt.Key_F +) +GROUP_KEY_SEQUENCE = QtGui.QKeySequence( + QtCore.Qt.Modifier.CTRL | QtCore.Qt.Key_G +) class LoadErrorMessageBox(ErrorMessageBox): @@ -182,29 +190,19 @@ class LoaderWindow(QtWidgets.QWidget): products_wrap_widget = QtWidgets.QWidget(main_splitter) products_inputs_widget = QtWidgets.QWidget(products_wrap_widget) - - products_filter_input = PlaceholderLineEdit(products_inputs_widget) - products_filter_input.setPlaceholderText("Product name filter...") - - product_types_filter_combo = ProductTypesCombobox( - controller, products_inputs_widget - ) - - product_status_filter_combo = StatusesCombobox(controller, self) + search_bar = FiltersBar(products_inputs_widget) product_group_checkbox = QtWidgets.QCheckBox( "Enable grouping", products_inputs_widget) product_group_checkbox.setChecked(True) - 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_types_filter_combo, 1) - products_inputs_layout.addWidget(product_status_filter_combo, 1) + products_inputs_layout.addWidget(search_bar, 1) products_inputs_layout.addWidget(product_group_checkbox, 0) + 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_inputs_widget, 0) @@ -250,15 +248,7 @@ class LoaderWindow(QtWidgets.QWidget): folders_filter_input.textChanged.connect( self._on_folder_filter_change ) - products_filter_input.textChanged.connect( - self._on_product_filter_change - ) - product_types_filter_combo.value_changed.connect( - self._on_product_type_filter_change - ) - product_status_filter_combo.value_changed.connect( - self._on_status_filter_change - ) + search_bar.filter_changed.connect(self._on_filter_change) product_group_checkbox.stateChanged.connect( self._on_product_group_change ) @@ -316,9 +306,7 @@ class LoaderWindow(QtWidgets.QWidget): self._tasks_widget = tasks_widget - self._products_filter_input = products_filter_input - self._product_types_filter_combo = product_types_filter_combo - self._product_status_filter_combo = product_status_filter_combo + self._search_bar = search_bar self._product_group_checkbox = product_group_checkbox self._products_widget = products_widget @@ -337,6 +325,8 @@ class LoaderWindow(QtWidgets.QWidget): self._selected_folder_ids = set() self._selected_version_ids = set() + self._set_product_type_filters = True + self._products_widget.set_enable_grouping( self._product_group_checkbox.isChecked() ) @@ -356,22 +346,24 @@ class LoaderWindow(QtWidgets.QWidget): def closeEvent(self, event): super().closeEvent(event) - ( - self - ._product_types_filter_combo - .reset_product_types_filter_on_refresh() - ) - self._reset_on_show = True def keyPressEvent(self, event): - modifiers = event.modifiers() - ctrl_pressed = QtCore.Qt.ControlModifier & modifiers + if hasattr(event, "keyCombination"): + combination = event.keyCombination() + else: + combination = QtGui.QKeySequence(event.modifiers() | event.key()) + if ( + FIND_KEY_SEQUENCE == combination + and not event.isAutoRepeat() + ): + self._search_bar.show_filters_popup() + event.setAccepted(True) + return # Grouping products on pressing Ctrl + G if ( - ctrl_pressed - and event.key() == QtCore.Qt.Key_G + GROUP_KEY_SEQUENCE == combination and not event.isAutoRepeat() ): self._show_group_dialog() @@ -435,20 +427,30 @@ class LoaderWindow(QtWidgets.QWidget): self._product_group_checkbox.isChecked() ) - def _on_product_filter_change(self, text): - self._products_widget.set_name_filter(text) + def _on_filter_change(self, filter_name): + if filter_name == "product_name": + self._products_widget.set_name_filter( + self._search_bar.get_filter_value("product_name") + ) + elif filter_name == "product_types": + product_types = self._search_bar.get_filter_value("product_types") + self._products_widget.set_product_type_filter(product_types) + + elif filter_name == "statuses": + status_names = self._search_bar.get_filter_value("statuses") + self._products_widget.set_statuses_filter(status_names) + + elif filter_name == "version_tags": + version_tags = self._search_bar.get_filter_value("version_tags") + self._products_widget.set_version_tags_filter(version_tags) + + elif filter_name == "task_tags": + task_tags = self._search_bar.get_filter_value("task_tags") + self._products_widget.set_task_tags_filter(task_tags) def _on_tasks_selection_change(self, event): self._products_widget.set_tasks_filter(event["task_ids"]) - def _on_status_filter_change(self): - status_names = self._product_status_filter_combo.get_value() - self._products_widget.set_statuses_filter(status_names) - - def _on_product_type_filter_change(self): - product_types = self._product_types_filter_combo.get_value() - self._products_widget.set_product_type_filter(product_types) - def _on_merged_products_selection_change(self): items = self._products_widget.get_selected_merged_products() self._folders_widget.set_merged_products_selection(items) @@ -480,6 +482,7 @@ class LoaderWindow(QtWidgets.QWidget): self._projects_combobox.set_current_context_project(project_name) if not self._refresh_handler.project_refreshed: self._projects_combobox.refresh() + self._update_filters() def _on_load_finished(self, event): error_info = event["error_info"] @@ -491,6 +494,124 @@ class LoaderWindow(QtWidgets.QWidget): def _on_project_selection_changed(self, event): self._selected_project_name = event["project_name"] + self._update_filters() + + def _update_filters(self): + project_name = self._selected_project_name + if not project_name: + self._search_bar.set_search_items([]) + return + + product_type_items: list[ProductTypeItem] = ( + self._controller.get_product_type_items(project_name) + ) + status_items: list[StatusItem] = ( + self._controller.get_project_status_items(project_name) + ) + tags_by_entity_type = ( + self._controller.get_available_tags_by_entity_type(project_name) + ) + tag_items = self._controller.get_project_anatomy_tags(project_name) + tag_color_by_name = { + tag_item.name: tag_item.color + for tag_item in tag_items + } + + filter_product_type_items = [ + { + "value": item.name, + "icon": item.icon, + } + for item in product_type_items + ] + filter_status_items = [ + { + "icon": { + "type": "material-symbols", + "name": status_item.icon, + "color": status_item.color + }, + "color": status_item.color, + "value": status_item.name, + } + for status_item in status_items + ] + version_tags = [ + { + "value": tag_name, + "color": tag_color_by_name.get(tag_name), + } + for tag_name in tags_by_entity_type.get("versions") or [] + ] + task_tags = [ + { + "value": tag_name, + "color": tag_color_by_name.get(tag_name), + } + for tag_name in tags_by_entity_type.get("tasks") or [] + ] + + self._search_bar.set_search_items([ + FilterDefinition( + name="product_name", + title="Product name", + filter_type="text", + icon=None, + placeholder="Product name filter...", + items=None, + ), + FilterDefinition( + name="product_types", + title="Product type", + filter_type="list", + icon=None, + items=filter_product_type_items, + ), + FilterDefinition( + name="statuses", + title="Statuses", + filter_type="list", + icon=None, + items=filter_status_items, + ), + FilterDefinition( + name="version_tags", + title="Version tags", + filter_type="list", + icon=None, + items=version_tags, + ), + FilterDefinition( + name="task_tags", + title="Task tags", + filter_type="list", + icon=None, + items=task_tags, + ), + ]) + + # Set product types filter from settings + if self._set_product_type_filters: + self._set_product_type_filters = False + product_types_filter = self._controller.get_product_types_filter() + product_types = [] + for item in filter_product_type_items: + product_type = item["value"] + matching = ( + int(product_type in product_types_filter.product_types) + + int(product_types_filter.is_allow_list) + ) + if matching % 2 == 0: + product_types.append(product_type) + + if ( + product_types + and len(product_types) < len(filter_product_type_items) + ): + self._search_bar.set_filter_value( + "product_types", + product_types + ) def _on_folders_selection_changed(self, event): self._selected_folder_ids = set(event["folder_ids"]) diff --git a/client/ayon_core/tools/publisher/widgets/screenshot_widget.py b/client/ayon_core/tools/publisher/widgets/screenshot_widget.py index 0706299f32..e9749c5b07 100644 --- a/client/ayon_core/tools/publisher/widgets/screenshot_widget.py +++ b/client/ayon_core/tools/publisher/widgets/screenshot_widget.py @@ -348,8 +348,6 @@ class ScreenMarquee(QtCore.QObject): # QtGui.QPainter.Antialiasing # | QtGui.QPainter.SmoothPixmapTransform # ) - # if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): - # render_hints |= QtGui.QPainter.HighQualityAntialiasing # pix_painter.setRenderHints(render_hints) # for item in screen_pixes: # (screen_pix, offset) = item diff --git a/client/ayon_core/tools/publisher/widgets/thumbnail_widget.py b/client/ayon_core/tools/publisher/widgets/thumbnail_widget.py index 261dcfb43d..f767fdf325 100644 --- a/client/ayon_core/tools/publisher/widgets/thumbnail_widget.py +++ b/client/ayon_core/tools/publisher/widgets/thumbnail_widget.py @@ -135,8 +135,6 @@ class ThumbnailPainterWidget(QtWidgets.QWidget): 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) @@ -171,8 +169,6 @@ class ThumbnailPainterWidget(QtWidgets.QWidget): 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( @@ -265,8 +261,6 @@ class ThumbnailPainterWidget(QtWidgets.QWidget): QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform ) - if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): - render_hints |= QtGui.QPainter.HighQualityAntialiasing final_painter.setRenderHints(render_hints) diff --git a/client/ayon_core/tools/utils/color_widgets/color_inputs.py b/client/ayon_core/tools/utils/color_widgets/color_inputs.py index 795b80fc1e..5a1c2dc50b 100644 --- a/client/ayon_core/tools/utils/color_widgets/color_inputs.py +++ b/client/ayon_core/tools/utils/color_widgets/color_inputs.py @@ -65,7 +65,7 @@ class AlphaSlider(QtWidgets.QSlider): painter.fillRect(event.rect(), QtCore.Qt.transparent) - painter.setRenderHint(QtGui.QPainter.HighQualityAntialiasing) + painter.setRenderHint(QtGui.QPainter.Antialiasing) horizontal = self.orientation() == QtCore.Qt.Horizontal diff --git a/client/ayon_core/tools/utils/color_widgets/color_triangle.py b/client/ayon_core/tools/utils/color_widgets/color_triangle.py index 290a33f0b0..7691c3e78d 100644 --- a/client/ayon_core/tools/utils/color_widgets/color_triangle.py +++ b/client/ayon_core/tools/utils/color_widgets/color_triangle.py @@ -261,7 +261,7 @@ class QtColorTriangle(QtWidgets.QWidget): pix = self.bg_image.copy() pix_painter = QtGui.QPainter(pix) - pix_painter.setRenderHint(QtGui.QPainter.HighQualityAntialiasing) + pix_painter.setRenderHint(QtGui.QPainter.Antialiasing) trigon_path = QtGui.QPainterPath() trigon_path.moveTo(self.point_a) diff --git a/client/ayon_core/tools/utils/lib.py b/client/ayon_core/tools/utils/lib.py index f7919a3317..a99c46199b 100644 --- a/client/ayon_core/tools/utils/lib.py +++ b/client/ayon_core/tools/utils/lib.py @@ -118,9 +118,6 @@ def paint_image_with_color(image, color): QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform ) - # Deprecated since 5.14 - if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): - render_hints |= QtGui.QPainter.HighQualityAntialiasing painter.setRenderHints(render_hints) painter.setClipRegion(alpha_region) diff --git a/client/ayon_core/tools/utils/sliders.py b/client/ayon_core/tools/utils/sliders.py index ea1e01b9ea..c762b6ade0 100644 --- a/client/ayon_core/tools/utils/sliders.py +++ b/client/ayon_core/tools/utils/sliders.py @@ -58,7 +58,7 @@ class NiceSlider(QtWidgets.QSlider): painter.fillRect(event.rect(), QtCore.Qt.transparent) - painter.setRenderHint(QtGui.QPainter.HighQualityAntialiasing) + painter.setRenderHint(QtGui.QPainter.Antialiasing) horizontal = self.orientation() == QtCore.Qt.Horizontal diff --git a/client/ayon_core/tools/utils/thumbnail_paint_widget.py b/client/ayon_core/tools/utils/thumbnail_paint_widget.py index 9dbc2bcdd0..e67b820417 100644 --- a/client/ayon_core/tools/utils/thumbnail_paint_widget.py +++ b/client/ayon_core/tools/utils/thumbnail_paint_widget.py @@ -205,8 +205,6 @@ class ThumbnailPainterWidget(QtWidgets.QWidget): 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) @@ -241,8 +239,6 @@ class ThumbnailPainterWidget(QtWidgets.QWidget): 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( @@ -335,8 +331,6 @@ class ThumbnailPainterWidget(QtWidgets.QWidget): QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform ) - if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): - render_hints |= QtGui.QPainter.HighQualityAntialiasing final_painter.setRenderHints(render_hints) diff --git a/client/ayon_core/tools/utils/widgets.py b/client/ayon_core/tools/utils/widgets.py index af0745af1f..de2c42c91f 100644 --- a/client/ayon_core/tools/utils/widgets.py +++ b/client/ayon_core/tools/utils/widgets.py @@ -462,7 +462,7 @@ class BaseClickableFrame(QtWidgets.QFrame): Callback is defined by overriding `_mouse_release_callback`. """ def __init__(self, parent): - super(BaseClickableFrame, self).__init__(parent) + super().__init__(parent) self._mouse_pressed = False @@ -470,17 +470,23 @@ class BaseClickableFrame(QtWidgets.QFrame): pass def mousePressEvent(self, event): + super().mousePressEvent(event) + if event.isAccepted(): + return if event.button() == QtCore.Qt.LeftButton: self._mouse_pressed = True - super(BaseClickableFrame, self).mousePressEvent(event) + event.accept() def mouseReleaseEvent(self, event): - if self._mouse_pressed: - self._mouse_pressed = False - if self.rect().contains(event.pos()): - self._mouse_release_callback() + pressed, self._mouse_pressed = self._mouse_pressed, False + super().mouseReleaseEvent(event) + if event.isAccepted(): + return - super(BaseClickableFrame, self).mouseReleaseEvent(event) + accepted = pressed and self.rect().contains(event.pos()) + if accepted: + event.accept() + self._mouse_release_callback() class ClickableFrame(BaseClickableFrame): @@ -624,8 +630,6 @@ class ClassicExpandBtnLabel(ExpandBtnLabel): QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform ) - if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): - render_hints |= QtGui.QPainter.HighQualityAntialiasing painter.setRenderHints(render_hints) painter.drawPixmap(QtCore.QPoint(pos_x, pos_y), pixmap) painter.end() @@ -788,8 +792,6 @@ class PixmapButtonPainter(QtWidgets.QWidget): QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform ) - if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): - render_hints |= QtGui.QPainter.HighQualityAntialiasing painter.setRenderHints(render_hints) if self._cached_pixmap is None: @@ -1189,7 +1191,7 @@ class SquareButton(QtWidgets.QPushButton): """ def __init__(self, *args, **kwargs): - super(SquareButton, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) sp = self.sizePolicy() sp.setVerticalPolicy(QtWidgets.QSizePolicy.Minimum) @@ -1198,17 +1200,17 @@ class SquareButton(QtWidgets.QPushButton): self._ideal_width = None def showEvent(self, event): - super(SquareButton, self).showEvent(event) + super().showEvent(event) self._ideal_width = self.height() self.updateGeometry() def resizeEvent(self, event): - super(SquareButton, self).resizeEvent(event) + super().resizeEvent(event) self._ideal_width = self.height() self.updateGeometry() def sizeHint(self): - sh = super(SquareButton, self).sizeHint() + sh = super().sizeHint() ideal_width = self._ideal_width if ideal_width is None: ideal_width = sh.height() diff --git a/pyproject.toml b/pyproject.toml index c932917224..f4a452a2b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ mdx-gh-links = "^0.4" pymdown-extensions = "^10.14.3" mike = "^2.1.3" mkdocstrings-shell = "^1.0.2" +nxtools = "^1.6" [tool.poetry.group.test.dependencies] attrs = "^25.0.0" diff --git a/server/__init__.py b/server/__init__.py index d60f50f471..620cb3285c 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -1,6 +1,15 @@ from typing import Any from ayon_server.addons import BaseServerAddon +from ayon_server.actions import ( + ActionExecutor, + ExecuteResponseModel, + SimpleActionManifest, +) +try: + from ayon_server.logging import logger +except ImportError: + from nxtools import logging as logger from .settings import ( CoreSettings, @@ -26,3 +35,67 @@ class CoreAddon(BaseServerAddon): return await super().convert_settings_overrides( source_version, overrides ) + + async def get_simple_actions( + self, + project_name: str | None = None, + variant: str = "production", + ) -> list[SimpleActionManifest]: + """Return a list of simple actions provided by the addon""" + output = [] + + if project_name: + # Add 'Create Project Folder Structure' action to folders. + output.append( + SimpleActionManifest( + identifier="core.createprojectstructure", + label="Create Project Folder Structure", + icon={ + "type": "material-symbols", + "name": "create_new_folder", + }, + order=100, + entity_type="project", + entity_subtypes=None, + allow_multiselection=False, + ) + ) + + return output + + async def execute_action( + self, + executor: ActionExecutor, + ) -> ExecuteResponseModel: + """Execute webactions.""" + + project_name = executor.context.project_name + + if executor.identifier == "core.createprojectstructure": + if not project_name: + logger.error( + f"Can't execute {executor.identifier} because" + " of missing project name." + ) + # Works since AYON server 1.8.3 + if hasattr(executor, "get_simple_response"): + return await executor.get_simple_response( + "Missing project name", success=False + ) + return + + args = [ + "create-project-structure", "--project", project_name, + ] + # Works since AYON server 1.8.3 + if hasattr(executor, "get_launcher_response"): + return await executor.get_launcher_response(args) + + return await executor.get_launcher_action_response(args) + + logger.debug(f"Unknown action: {executor.identifier}") + # Works since AYON server 1.8.3 + if hasattr(executor, "get_simple_response"): + return await executor.get_simple_response( + "Unknown action", success=False + ) diff --git a/server/settings/main.py b/server/settings/main.py index dd6af0a104..cca885303f 100644 --- a/server/settings/main.py +++ b/server/settings/main.py @@ -106,7 +106,7 @@ class FallbackProductModel(BaseSettingsModel): fallback_type: str = SettingsField( title="Fallback config type", enum_resolver=_fallback_ocio_config_profile_types, - conditionalEnum=True, + conditional_enum=True, default="builtin_path", description=( "Type of config which needs to be used in case published " @@ -162,7 +162,7 @@ class CoreImageIOConfigProfilesModel(BaseSettingsModel): type: str = SettingsField( title="Profile type", enum_resolver=_ocio_config_profile_types, - conditionalEnum=True, + conditional_enum=True, default="builtin_path", section="---", ) @@ -319,6 +319,10 @@ class CoreSettings(BaseSettingsModel): "{}", widget="textarea", title="Project folder structure", + description=( + "Defines project folders to create on disk" + " for 'Create project folders' action." + ), section="---" ) project_environments: str = SettingsField( diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index 793ca659e5..d690d79607 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -340,7 +340,7 @@ class ResizeModel(BaseSettingsModel): title="Type", description="Type of resizing", enum_resolver=lambda: _resize_types_enum, - conditionalEnum=True, + conditional_enum=True, default="source" ) @@ -373,7 +373,7 @@ class ExtractThumbnailOIIODefaultsModel(BaseSettingsModel): title="Type", description="Transcoding type", enum_resolver=lambda: _thumbnail_oiio_transcoding_type, - conditionalEnum=True, + conditional_enum=True, default="colorspace" ) @@ -476,7 +476,7 @@ class ExtractOIIOTranscodeOutputModel(BaseSettingsModel): "colorspace", title="Transcoding type", enum_resolver=_extract_oiio_transcoding_type, - conditionalEnum=True, + conditional_enum=True, description=( "Select the transcoding type for your output, choosing either " "*Colorspace* or *Display&View* transform." diff --git a/tests/client/ayon_core/pipeline/load/test_loaders.py b/tests/client/ayon_core/pipeline/load/test_loaders.py new file mode 100644 index 0000000000..490efe1b1e --- /dev/null +++ b/tests/client/ayon_core/pipeline/load/test_loaders.py @@ -0,0 +1,88 @@ +"""Test loaders in the pipeline module.""" + +from ayon_core.pipeline.load import LoaderPlugin + + +def test_is_compatible_loader(): + """Test if a loader is compatible with a given representation.""" + from ayon_core.pipeline.load import is_compatible_loader + + # Create a mock representation context + context = { + "loader": "test_loader", + "representation": {"name": "test_representation"}, + } + + # Create a mock loader plugin + class MockLoader(LoaderPlugin): + name = "test_loader" + version = "1.0.0" + + def is_compatible_loader(self, context): + return True + + # Check compatibility + assert is_compatible_loader(MockLoader(), context) is True + + +def test_complex_is_compatible_loader(): + """Test if a loader is compatible with a complex representation.""" + from ayon_core.pipeline.load import is_compatible_loader + + # Create a mock complex representation context + context = { + "loader": "complex_loader", + "representation": { + "name": "complex_representation", + "extension": "exr" + }, + "additional_data": {"key": "value"}, + "product": { + "name": "complex_product", + "productType": "foo", + "productBaseType": "bar", + }, + } + + # Create a mock loader plugin + class ComplexLoaderA(LoaderPlugin): + name = "complex_loaderA" + + # False because the loader doesn't specify any compatibility (missing + # wildcard for product type and product base type) + assert is_compatible_loader(ComplexLoaderA(), context) is False + + class ComplexLoaderB(LoaderPlugin): + name = "complex_loaderB" + product_types = {"*"} + representations = {"*"} + + # True, it is compatible with any product type + assert is_compatible_loader(ComplexLoaderB(), context) is True + + class ComplexLoaderC(LoaderPlugin): + name = "complex_loaderC" + product_base_types = {"*"} + representations = {"*"} + + # True, it is compatible with any product base type + assert is_compatible_loader(ComplexLoaderC(), context) is True + + class ComplexLoaderD(LoaderPlugin): + name = "complex_loaderD" + product_types = {"foo"} + representations = {"*"} + + # legacy loader defining compatibility only with product type + # is compatible provided the same product type is defined in context + assert is_compatible_loader(ComplexLoaderD(), context) is False + + class ComplexLoaderE(LoaderPlugin): + name = "complex_loaderE" + product_types = {"foo"} + representations = {"*"} + + # remove productBaseType from context to simulate legacy behavior + context["product"].pop("productBaseType", None) + + assert is_compatible_loader(ComplexLoaderE(), context) is True