diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index 0b790dfbbd..26b476de1f 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -108,6 +108,7 @@ class VersionItem: version (int): Version. Can be negative when is hero version. is_hero (bool): Is hero version. product_id (str): Product id. + task_id (Union[str, None]): Task id. thumbnail_id (Union[str, None]): Thumbnail id. published_time (Union[str, None]): Published time in format '%Y%m%dT%H%M%SZ'. @@ -127,6 +128,7 @@ class VersionItem: version, is_hero, product_id, + task_id, thumbnail_id, published_time, author, @@ -140,6 +142,7 @@ class VersionItem: ): self.version_id = version_id self.product_id = product_id + self.task_id = task_id self.thumbnail_id = thumbnail_id self.version = version self.is_hero = is_hero @@ -161,6 +164,7 @@ class VersionItem: and self.version == other.version and self.version_id == other.version_id and self.product_id == other.product_id + and self.task_id == other.task_id ) def __ne__(self, other): @@ -198,6 +202,7 @@ class VersionItem: return { "version_id": self.version_id, "product_id": self.product_id, + "task_id": self.task_id, "thumbnail_id": self.thumbnail_id, "version": self.version, "is_hero": self.is_hero, @@ -536,6 +541,55 @@ class FrontendLoaderController(_BaseLoaderController): """ pass + @abstractmethod + def get_task_items(self, project_name, folder_ids, sender=None): + """Task items for folder ids. + + Args: + project_name (str): Project name. + folder_ids (Iterable[str]): Folder ids. + sender (Optional[str]): Sender who requested the items. + + Returns: + list[TaskItem]: List of task items. + + """ + pass + + @abstractmethod + def get_task_type_items(self, project_name, sender=None): + """Task type items for a project. + + This function may trigger events with topics + 'projects.task_types.refresh.started' and + 'projects.task_types.refresh.finished' which will contain 'sender' + value in data. + That may help to avoid re-refresh of items in UI elements. + + Args: + project_name (str): Project name. + sender (str): Who requested task type items. + + Returns: + list[TaskTypeItem]: Task type information. + + """ + pass + + @abstractmethod + def get_folder_labels(self, project_name, folder_ids): + """Get folder labels for folder ids. + + Args: + project_name (str): Project name. + folder_ids (Iterable[str]): Folder ids. + + Returns: + dict[str, Optional[str]]: Folder labels by folder id. + + """ + pass + @abstractmethod def get_project_status_items(self, project_name, sender=None): """Items for all projects available on server. @@ -717,8 +771,30 @@ class FrontendLoaderController(_BaseLoaderController): Returns: list[str]: Selected folder ids. - """ + """ + pass + + @abstractmethod + def get_selected_task_ids(self): + """Get selected task ids. + + The information is based on last selection from UI. + + Returns: + list[str]: Selected folder ids. + + """ + pass + + @abstractmethod + def set_selected_tasks(self, task_ids): + """Set selected tasks. + + Args: + task_ids (Iterable[str]): Selected task ids. + + """ pass @abstractmethod @@ -729,8 +805,8 @@ class FrontendLoaderController(_BaseLoaderController): Returns: list[str]: Selected version ids. - """ + """ pass @abstractmethod diff --git a/client/ayon_core/tools/loader/control.py b/client/ayon_core/tools/loader/control.py index 16cf7c31c7..7959a63edb 100644 --- a/client/ayon_core/tools/loader/control.py +++ b/client/ayon_core/tools/loader/control.py @@ -198,6 +198,31 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): def get_folder_items(self, project_name, sender=None): return self._hierarchy_model.get_folder_items(project_name, sender) + def get_task_items(self, project_name, folder_ids, sender=None): + output = [] + for folder_id in folder_ids: + output.extend(self._hierarchy_model.get_task_items( + project_name, folder_id, sender + )) + return output + + def get_task_type_items(self, project_name, sender=None): + return self._projects_model.get_task_type_items( + project_name, sender + ) + + def get_folder_labels(self, project_name, folder_ids): + folder_items_by_id = self._hierarchy_model.get_folder_items_by_id( + project_name, folder_ids + ) + output = {} + for folder_id, folder_item in folder_items_by_id.items(): + label = None + if folder_item is not None: + label = folder_item.label + output[folder_id] = label + return output + def get_product_items(self, project_name, folder_ids, sender=None): return self._products_model.get_product_items( project_name, folder_ids, sender) @@ -299,6 +324,12 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): def set_selected_folders(self, folder_ids): self._selection_model.set_selected_folders(folder_ids) + def get_selected_task_ids(self): + return self._selection_model.get_selected_task_ids() + + def set_selected_tasks(self, task_ids): + self._selection_model.set_selected_tasks(task_ids) + def get_selected_version_ids(self): return self._selection_model.get_selected_version_ids() diff --git a/client/ayon_core/tools/loader/models/products.py b/client/ayon_core/tools/loader/models/products.py index 58eab0cabe..34acc0550c 100644 --- a/client/ayon_core/tools/loader/models/products.py +++ b/client/ayon_core/tools/loader/models/products.py @@ -55,6 +55,7 @@ def version_item_from_entity(version): version=version_num, is_hero=is_hero, product_id=version["productId"], + task_id=version["taskId"], thumbnail_id=version["thumbnailId"], published_time=published_time, author=author, diff --git a/client/ayon_core/tools/loader/models/selection.py b/client/ayon_core/tools/loader/models/selection.py index 326ff835f6..04add26f86 100644 --- a/client/ayon_core/tools/loader/models/selection.py +++ b/client/ayon_core/tools/loader/models/selection.py @@ -14,6 +14,7 @@ class SelectionModel(object): self._project_name = None self._folder_ids = set() + self._task_ids = set() self._version_ids = set() self._representation_ids = set() @@ -48,6 +49,23 @@ class SelectionModel(object): self.event_source ) + def get_selected_task_ids(self): + return self._task_ids + + def set_selected_tasks(self, task_ids): + if task_ids == self._task_ids: + return + + self._task_ids = task_ids + self._controller.emit_event( + "selection.tasks.changed", + { + "project_name": self._project_name, + "task_ids": task_ids, + }, + self.event_source + ) + def get_selected_version_ids(self): return self._version_ids diff --git a/client/ayon_core/tools/loader/ui/_multicombobox.py b/client/ayon_core/tools/loader/ui/_multicombobox.py index 9efe57ef0f..393272fdf9 100644 --- a/client/ayon_core/tools/loader/ui/_multicombobox.py +++ b/client/ayon_core/tools/loader/ui/_multicombobox.py @@ -1,7 +1,10 @@ +from __future__ import annotations +import typing from typing import List, Tuple, Optional, Iterable, Any from qtpy import QtWidgets, QtCore, QtGui +from ayon_core.tools.utils import get_qt_icon from ayon_core.tools.utils.lib import ( checkstate_int_to_enum, checkstate_enum_to_int, @@ -11,14 +14,269 @@ from ayon_core.tools.utils.constants import ( UNCHECKED_INT, ITEM_IS_USER_TRISTATE, ) +if typing.TYPE_CHECKING: + from ayon_core.tools.loader.abstract import FrontendLoaderController VALUE_ITEM_TYPE = 0 STANDARD_ITEM_TYPE = 1 SEPARATOR_ITEM_TYPE = 2 +VALUE_ITEM_SUBTYPE = 0 +SELECT_ALL_SUBTYPE = 1 +DESELECT_ALL_SUBTYPE = 2 +SWAP_STATE_SUBTYPE = 3 + + +class BaseQtModel(QtGui.QStandardItemModel): + _empty_icon = None + + def __init__( + self, + item_type_role: int, + item_subtype_role: int, + empty_values_label: str, + controller: FrontendLoaderController, + ): + self._item_type_role = item_type_role + self._item_subtype_role = item_subtype_role + self._empty_values_label = empty_values_label + self._controller = controller + + self._last_project = None + + self._select_project_item = None + self._empty_values_item = None + + self._select_all_item = None + self._deselect_all_item = None + self._swap_states_item = None + + super().__init__() + + self.refresh(None) + + def _get_standard_items(self) -> list[QtGui.QStandardItem]: + raise NotImplementedError( + "'_get_standard_items' is not implemented" + f" for {self.__class__}" + ) + + def _clear_standard_items(self): + raise NotImplementedError( + "'_clear_standard_items' is not implemented" + f" for {self.__class__}" + ) + + def _prepare_new_value_items( + self, project_name: str, project_changed: bool + ) -> tuple[ + list[QtGui.QStandardItem], list[QtGui.QStandardItem] + ]: + raise NotImplementedError( + "'_prepare_new_value_items' is not implemented" + f" for {self.__class__}" + ) + + def refresh(self, project_name: Optional[str]): + # New project was selected + project_changed = False + if project_name != self._last_project: + self._last_project = project_name + project_changed = True + + if project_name is None: + self._add_select_project_item() + return + + value_items, items_to_remove = self._prepare_new_value_items( + project_name, project_changed + ) + if not value_items: + self._add_empty_values_item() + return + + self._remove_empty_items() + + root_item = self.invisibleRootItem() + for row_idx, value_item in enumerate(value_items): + if value_item.row() == row_idx: + continue + if value_item.row() >= 0: + root_item.takeRow(value_item.row()) + root_item.insertRow(row_idx, value_item) + + for item in items_to_remove: + root_item.removeRow(item.row()) + + self._add_selection_items() + + def setData(self, index, value, role): + if role == QtCore.Qt.CheckStateRole and index.isValid(): + item_subtype = index.data(self._item_subtype_role) + if item_subtype == SELECT_ALL_SUBTYPE: + for item in self._get_standard_items(): + item.setCheckState(QtCore.Qt.Checked) + return True + if item_subtype == DESELECT_ALL_SUBTYPE: + for item in self._get_standard_items(): + item.setCheckState(QtCore.Qt.Unchecked) + return True + if item_subtype == SWAP_STATE_SUBTYPE: + for item in self._get_standard_items(): + current_state = item.checkState() + item.setCheckState( + QtCore.Qt.Checked + if current_state == QtCore.Qt.Unchecked + else QtCore.Qt.Unchecked + ) + return True + return super().setData(index, value, role) + + @classmethod + def _get_empty_icon(cls): + if cls._empty_icon is None: + pix = QtGui.QPixmap(1, 1) + pix.fill(QtCore.Qt.transparent) + cls._empty_icon = QtGui.QIcon(pix) + return cls._empty_icon + + def _init_default_items(self): + if self._empty_values_item is not None: + return + + empty_values_item = QtGui.QStandardItem(self._empty_values_label) + select_project_item = QtGui.QStandardItem("Select project...") + + select_all_item = QtGui.QStandardItem("Select all") + deselect_all_item = QtGui.QStandardItem("Deselect all") + swap_states_item = QtGui.QStandardItem("Swap") + + for item in ( + empty_values_item, + select_project_item, + select_all_item, + deselect_all_item, + swap_states_item, + ): + item.setData(STANDARD_ITEM_TYPE, self._item_type_role) + + select_all_item.setIcon(get_qt_icon({ + "type": "material-symbols", + "name": "done_all", + "color": "white" + })) + deselect_all_item.setIcon(get_qt_icon({ + "type": "material-symbols", + "name": "remove_done", + "color": "white" + })) + swap_states_item.setIcon(get_qt_icon({ + "type": "material-symbols", + "name": "swap_horiz", + "color": "white" + })) + + for item in ( + empty_values_item, + select_project_item, + ): + item.setFlags(QtCore.Qt.NoItemFlags) + + for item, item_type in ( + (select_all_item, SELECT_ALL_SUBTYPE), + (deselect_all_item, DESELECT_ALL_SUBTYPE), + (swap_states_item, SWAP_STATE_SUBTYPE), + ): + item.setData(item_type, self._item_subtype_role) + + for item in ( + select_all_item, + deselect_all_item, + swap_states_item, + ): + item.setFlags( + QtCore.Qt.ItemIsEnabled + | QtCore.Qt.ItemIsSelectable + | QtCore.Qt.ItemIsUserCheckable + ) + + self._empty_values_item = empty_values_item + self._select_project_item = select_project_item + + self._select_all_item = select_all_item + self._deselect_all_item = deselect_all_item + self._swap_states_item = swap_states_item + + def _get_empty_values_item(self): + self._init_default_items() + return self._empty_values_item + + def _get_select_project_item(self): + self._init_default_items() + return self._select_project_item + + def _get_empty_items(self): + self._init_default_items() + return [ + self._empty_values_item, + self._select_project_item, + ] + + def _get_selection_items(self): + self._init_default_items() + return [ + self._select_all_item, + self._deselect_all_item, + self._swap_states_item, + ] + + def _get_default_items(self): + return self._get_empty_items() + self._get_selection_items() + + def _add_select_project_item(self): + item = self._get_select_project_item() + if item.row() < 0: + self._remove_items() + root_item = self.invisibleRootItem() + root_item.appendRow(item) + + def _add_empty_values_item(self): + item = self._get_empty_values_item() + if item.row() < 0: + self._remove_items() + root_item = self.invisibleRootItem() + root_item.appendRow(item) + + def _add_selection_items(self): + root_item = self.invisibleRootItem() + items = self._get_selection_items() + for item in self._get_selection_items(): + row = item.row() + if row >= 0: + root_item.takeRow(row) + root_item.appendRows(items) + + def _remove_items(self): + root_item = self.invisibleRootItem() + for item in self._get_default_items(): + if item.row() < 0: + continue + root_item.takeRow(item.row()) + + root_item.removeRows(0, root_item.rowCount()) + self._clear_standard_items() + + def _remove_empty_items(self): + root_item = self.invisibleRootItem() + for item in self._get_empty_items(): + if item.row() < 0: + continue + root_item.takeRow(item.row()) + class CustomPaintDelegate(QtWidgets.QStyledItemDelegate): """Delegate showing status name and short name.""" + _empty_icon = None _checked_value = checkstate_enum_to_int(QtCore.Qt.Checked) _checked_bg_color = QtGui.QColor("#2C3B4C") @@ -38,6 +296,14 @@ class CustomPaintDelegate(QtWidgets.QStyledItemDelegate): self._icon_role = icon_role self._item_type_role = item_type_role + @classmethod + def _get_empty_icon(cls): + if cls._empty_icon is None: + pix = QtGui.QPixmap(1, 1) + pix.fill(QtCore.Qt.transparent) + cls._empty_icon = QtGui.QIcon(pix) + return cls._empty_icon + def paint(self, painter, option, index): item_type = None if self._item_type_role is not None: @@ -70,6 +336,9 @@ class CustomPaintDelegate(QtWidgets.QStyledItemDelegate): if option.state & QtWidgets.QStyle.State_Open: state = QtGui.QIcon.On icon = self._get_index_icon(index) + if icon is None or icon.isNull(): + icon = self._get_empty_icon() + option.features |= QtWidgets.QStyleOptionViewItem.HasDecoration # Disable visible check indicator @@ -241,6 +510,10 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox): QtCore.Qt.Key_Home, QtCore.Qt.Key_End, } + _top_bottom_margins = 1 + _top_bottom_padding = 2 + _left_right_padding = 3 + _item_bg_color = QtGui.QColor("#31424e") def __init__( self, @@ -433,14 +706,14 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox): idxs = self._get_checked_idx() # draw the icon and text - draw_text = True + draw_items = False combotext = None if self._custom_text is not None: combotext = self._custom_text elif not idxs: combotext = self._placeholder_text else: - draw_text = False + draw_items = True content_field_rect = self.style().subControlRect( QtWidgets.QStyle.CC_ComboBox, @@ -448,7 +721,9 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox): QtWidgets.QStyle.SC_ComboBoxEditField ).adjusted(1, 0, -1, 0) - if draw_text: + if draw_items: + self._paint_items(painter, idxs, content_field_rect) + else: color = option.palette.color(QtGui.QPalette.Text) color.setAlpha(67) pen = painter.pen() @@ -459,15 +734,12 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox): QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter, combotext ) - else: - self._paint_items(painter, idxs, content_field_rect) painter.end() def _paint_items(self, painter, indexes, content_rect): origin_rect = QtCore.QRect(content_rect) - metrics = self.fontMetrics() model = self.model() available_width = content_rect.width() total_used_width = 0 @@ -482,31 +754,80 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox): continue icon = index.data(self._icon_role) - # TODO handle this case - if icon is None or icon.isNull(): - continue + text = index.data(self._text_role) + valid_icon = icon is not None and not icon.isNull() + if valid_icon: + sizes = icon.availableSizes() + if sizes: + valid_icon = any(size.width() > 1 for size in sizes) - icon_rect = QtCore.QRect(content_rect) - diff = icon_rect.height() - metrics.height() - if diff < 0: - diff = 0 - top_offset = diff // 2 - bottom_offset = diff - top_offset - icon_rect.adjust(0, top_offset, 0, -bottom_offset) - icon_rect.setWidth(metrics.height()) - icon.paint( - painter, - icon_rect, - QtCore.Qt.AlignCenter, - QtGui.QIcon.Normal, - QtGui.QIcon.On - ) - content_rect.setLeft(icon_rect.right() + spacing) - if total_used_width > 0: - total_used_width += spacing - total_used_width += icon_rect.width() - if total_used_width > available_width: - break + if valid_icon: + metrics = self.fontMetrics() + icon_rect = QtCore.QRect(content_rect) + diff = icon_rect.height() - metrics.height() + if diff < 0: + diff = 0 + top_offset = diff // 2 + bottom_offset = diff - top_offset + icon_rect.adjust(0, top_offset, 0, -bottom_offset) + used_width = metrics.height() + if total_used_width > 0: + total_used_width += spacing + total_used_width += used_width + if total_used_width > available_width: + break + + icon_rect.setWidth(used_width) + icon.paint( + painter, + icon_rect, + QtCore.Qt.AlignCenter, + QtGui.QIcon.Normal, + QtGui.QIcon.On + ) + content_rect.setLeft(icon_rect.right() + spacing) + + elif text: + bg_height = ( + content_rect.height() + - (2 * self._top_bottom_margins) + ) + font_height = bg_height - (2 * self._top_bottom_padding) + + bg_top_y = content_rect.y() + self._top_bottom_margins + + font = self.font() + font.setPixelSize(font_height) + metrics = QtGui.QFontMetrics(font) + painter.setFont(font) + + label_rect = metrics.boundingRect(text) + + bg_width = label_rect.width() + (2 * self._left_right_padding) + if total_used_width > 0: + total_used_width += spacing + total_used_width += bg_width + if total_used_width > available_width: + break + + bg_rect = QtCore.QRectF(label_rect) + bg_rect.moveTop(bg_top_y) + bg_rect.moveLeft(content_rect.left()) + bg_rect.setWidth(bg_width) + bg_rect.setHeight(bg_height) + + label_rect.moveTop(bg_top_y) + label_rect.moveLeft( + content_rect.left() + self._left_right_padding + ) + + path = QtGui.QPainterPath() + path.addRoundedRect(bg_rect, 5, 5) + + painter.fillPath(path, self._item_bg_color) + painter.drawText(label_rect, QtCore.Qt.AlignCenter, text) + + content_rect.setLeft(bg_rect.right() + spacing) painter.restore() diff --git a/client/ayon_core/tools/loader/ui/product_types_combo.py b/client/ayon_core/tools/loader/ui/product_types_combo.py new file mode 100644 index 0000000000..91fa52b0e9 --- /dev/null +++ b/client/ayon_core/tools/loader/ui/product_types_combo.py @@ -0,0 +1,169 @@ +from qtpy import QtGui, QtCore + +from ._multicombobox import ( + CustomPaintMultiselectComboBox, + BaseQtModel, +) + +STATUS_ITEM_TYPE = 0 +SELECT_ALL_TYPE = 1 +DESELECT_ALL_TYPE = 2 +SWAP_STATE_TYPE = 3 + +PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 1 +ITEM_TYPE_ROLE = QtCore.Qt.UserRole + 2 +ITEM_SUBTYPE_ROLE = QtCore.Qt.UserRole + 3 + + +class ProductTypesQtModel(BaseQtModel): + refreshed = QtCore.Signal() + + def __init__(self, controller): + self._reset_filters_on_refresh = True + self._refreshing = False + self._bulk_change = False + self._items_by_name = {} + + super().__init__( + item_type_role=ITEM_TYPE_ROLE, + item_subtype_role=ITEM_SUBTYPE_ROLE, + empty_values_label="No product types...", + controller=controller, + ) + + def is_refreshing(self): + return self._refreshing + + def refresh(self, project_name): + self._refreshing = True + super().refresh(project_name) + + self._reset_filters_on_refresh = False + self._refreshing = False + self.refreshed.emit() + + def reset_product_types_filter_on_refresh(self): + self._reset_filters_on_refresh = True + + def _get_standard_items(self) -> list[QtGui.QStandardItem]: + return list(self._items_by_name.values()) + + def _clear_standard_items(self): + self._items_by_name.clear() + + def _prepare_new_value_items(self, project_name: str, _: bool) -> tuple[ + list[QtGui.QStandardItem], list[QtGui.QStandardItem] + ]: + product_type_items = self._controller.get_product_type_items( + project_name) + self._last_project = project_name + + names_to_remove = set(self._items_by_name.keys()) + items = [] + items_filter_required = {} + for product_type_item in product_type_items: + name = product_type_item.name + names_to_remove.discard(name) + item = self._items_by_name.get(name) + # Apply filter to new items or if filters reset is requested + filter_required = self._reset_filters_on_refresh + if item is None: + filter_required = True + item = QtGui.QStandardItem(name) + item.setData(name, PRODUCT_TYPE_ROLE) + item.setEditable(False) + item.setCheckable(True) + self._items_by_name[name] = item + + items.append(item) + + if filter_required: + items_filter_required[name] = item + + if items_filter_required: + product_types_filter = self._controller.get_product_types_filter() + for product_type, item in items_filter_required.items(): + matching = ( + int(product_type in product_types_filter.product_types) + + int(product_types_filter.is_allow_list) + ) + item.setCheckState( + QtCore.Qt.Checked + if matching % 2 == 0 + else QtCore.Qt.Unchecked + ) + + items_to_remove = [] + for name in names_to_remove: + items_to_remove.append( + self._items_by_name.pop(name) + ) + + # Uncheck all if all are checked (same result) + if all( + item.checkState() == QtCore.Qt.Checked + for item in items + ): + for item in items: + item.setCheckState(QtCore.Qt.Unchecked) + + return items, items_to_remove + + +class ProductTypesCombobox(CustomPaintMultiselectComboBox): + def __init__(self, controller, parent): + self._controller = controller + model = ProductTypesQtModel(controller) + super().__init__( + PRODUCT_TYPE_ROLE, + PRODUCT_TYPE_ROLE, + QtCore.Qt.ForegroundRole, + QtCore.Qt.DecorationRole, + item_type_role=ITEM_TYPE_ROLE, + model=model, + parent=parent + ) + + model.refreshed.connect(self._on_model_refresh) + + self.set_placeholder_text("Product types filter...") + self._model = model + self._last_project_name = None + self._fully_disabled_filter = False + + controller.register_event_callback( + "selection.project.changed", + self._on_project_change + ) + controller.register_event_callback( + "projects.refresh.finished", + self._on_projects_refresh + ) + self.setToolTip("Product types filter") + self.value_changed.connect( + self._on_product_type_filter_change + ) + + def reset_product_types_filter_on_refresh(self): + self._model.reset_product_types_filter_on_refresh() + + def _on_model_refresh(self): + self.value_changed.emit() + + def _on_product_type_filter_change(self): + lines = ["Product types filter"] + for item in self.get_value_info(): + status_name, enabled = item + lines.append(f"{'✔' if enabled else '☐'} {status_name}") + + self.setToolTip("\n".join(lines)) + + def _on_project_change(self, event): + project_name = event["project_name"] + self._last_project_name = project_name + self._model.refresh(project_name) + + def _on_projects_refresh(self): + if self._last_project_name: + self._model.refresh(self._last_project_name) + self._on_product_type_filter_change() diff --git a/client/ayon_core/tools/loader/ui/product_types_widget.py b/client/ayon_core/tools/loader/ui/product_types_widget.py deleted file mode 100644 index 9b1bf6326f..0000000000 --- a/client/ayon_core/tools/loader/ui/product_types_widget.py +++ /dev/null @@ -1,256 +0,0 @@ -from qtpy import QtWidgets, QtGui, QtCore - -from ayon_core.tools.utils import get_qt_icon - -PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 1 - - -class ProductTypesQtModel(QtGui.QStandardItemModel): - refreshed = QtCore.Signal() - filter_changed = QtCore.Signal() - - def __init__(self, controller): - super(ProductTypesQtModel, self).__init__() - self._controller = controller - - self._reset_filters_on_refresh = True - self._refreshing = False - self._bulk_change = False - self._last_project = None - self._items_by_name = {} - - controller.register_event_callback( - "controller.reset.finished", - self._on_controller_reset_finish, - ) - - def is_refreshing(self): - return self._refreshing - - def get_filter_info(self): - """Product types filtering info. - - Returns: - dict[str, bool]: Filtering value by product type name. False value - means to hide product type. - """ - - return { - name: item.checkState() == QtCore.Qt.Checked - for name, item in self._items_by_name.items() - } - - def refresh(self, project_name): - self._refreshing = True - product_type_items = self._controller.get_product_type_items( - project_name) - self._last_project = project_name - - items_to_remove = set(self._items_by_name.keys()) - new_items = [] - items_filter_required = {} - for product_type_item in product_type_items: - name = product_type_item.name - items_to_remove.discard(name) - item = self._items_by_name.get(name) - # Apply filter to new items or if filters reset is requested - filter_required = self._reset_filters_on_refresh - if item is None: - filter_required = True - item = QtGui.QStandardItem(name) - item.setData(name, PRODUCT_TYPE_ROLE) - item.setEditable(False) - item.setCheckable(True) - new_items.append(item) - self._items_by_name[name] = item - - if filter_required: - items_filter_required[name] = item - - icon = get_qt_icon(product_type_item.icon) - item.setData(icon, QtCore.Qt.DecorationRole) - - if items_filter_required: - product_types_filter = self._controller.get_product_types_filter() - for product_type, item in items_filter_required.items(): - matching = ( - int(product_type in product_types_filter.product_types) - + int(product_types_filter.is_allow_list) - ) - state = ( - QtCore.Qt.Checked - if matching % 2 == 0 - else QtCore.Qt.Unchecked - ) - item.setCheckState(state) - - root_item = self.invisibleRootItem() - if new_items: - root_item.appendRows(new_items) - - for name in items_to_remove: - item = self._items_by_name.pop(name) - root_item.removeRow(item.row()) - - self._reset_filters_on_refresh = False - self._refreshing = False - self.refreshed.emit() - - def reset_product_types_filter_on_refresh(self): - self._reset_filters_on_refresh = True - - def setData(self, index, value, role=None): - checkstate_changed = False - if role is None: - role = QtCore.Qt.EditRole - elif role == QtCore.Qt.CheckStateRole: - checkstate_changed = True - output = super(ProductTypesQtModel, self).setData(index, value, role) - if checkstate_changed and not self._bulk_change: - self.filter_changed.emit() - return output - - def change_state_for_all(self, checked): - if self._items_by_name: - self.change_states(checked, self._items_by_name.keys()) - - def change_states(self, checked, product_types): - product_types = set(product_types) - if not product_types: - return - - if checked is None: - state = None - elif checked: - state = QtCore.Qt.Checked - else: - state = QtCore.Qt.Unchecked - - self._bulk_change = True - - changed = False - for product_type in product_types: - item = self._items_by_name.get(product_type) - if item is None: - continue - new_state = state - item_checkstate = item.checkState() - if new_state is None: - if item_checkstate == QtCore.Qt.Checked: - new_state = QtCore.Qt.Unchecked - else: - new_state = QtCore.Qt.Checked - elif item_checkstate == new_state: - continue - changed = True - item.setCheckState(new_state) - - self._bulk_change = False - - if changed: - self.filter_changed.emit() - - def _on_controller_reset_finish(self): - self.refresh(self._last_project) - - -class ProductTypesView(QtWidgets.QListView): - filter_changed = QtCore.Signal() - - def __init__(self, controller, parent): - super(ProductTypesView, self).__init__(parent) - - self.setSelectionMode( - QtWidgets.QAbstractItemView.ExtendedSelection - ) - self.setAlternatingRowColors(True) - self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) - - product_types_model = ProductTypesQtModel(controller) - product_types_proxy_model = QtCore.QSortFilterProxyModel() - product_types_proxy_model.setSourceModel(product_types_model) - - self.setModel(product_types_proxy_model) - - product_types_model.refreshed.connect(self._on_refresh_finished) - product_types_model.filter_changed.connect(self._on_filter_change) - self.customContextMenuRequested.connect(self._on_context_menu) - - controller.register_event_callback( - "selection.project.changed", - self._on_project_change - ) - - self._controller = controller - self._refresh_product_types_filter = False - - self._product_types_model = product_types_model - self._product_types_proxy_model = product_types_proxy_model - - def get_filter_info(self): - return self._product_types_model.get_filter_info() - - def reset_product_types_filter_on_refresh(self): - self._product_types_model.reset_product_types_filter_on_refresh() - - def _on_project_change(self, event): - project_name = event["project_name"] - self._product_types_model.refresh(project_name) - - def _on_refresh_finished(self): - # Apply product types filter on first show - self.filter_changed.emit() - - def _on_filter_change(self): - if not self._product_types_model.is_refreshing(): - self.filter_changed.emit() - - def _change_selection_state(self, checkstate): - selection_model = self.selectionModel() - product_types = { - index.data(PRODUCT_TYPE_ROLE) - for index in selection_model.selectedIndexes() - } - product_types.discard(None) - self._product_types_model.change_states(checkstate, product_types) - - def _on_enable_all(self): - self._product_types_model.change_state_for_all(True) - - def _on_disable_all(self): - self._product_types_model.change_state_for_all(False) - - def _on_context_menu(self, pos): - menu = QtWidgets.QMenu(self) - - # Add enable all action - action_check_all = QtWidgets.QAction(menu) - action_check_all.setText("Enable All") - action_check_all.triggered.connect(self._on_enable_all) - # Add disable all action - action_uncheck_all = QtWidgets.QAction(menu) - action_uncheck_all.setText("Disable All") - action_uncheck_all.triggered.connect(self._on_disable_all) - - menu.addAction(action_check_all) - menu.addAction(action_uncheck_all) - - # Get mouse position - global_pos = self.viewport().mapToGlobal(pos) - menu.exec_(global_pos) - - def event(self, event): - if event.type() == QtCore.QEvent.KeyPress: - if event.key() == QtCore.Qt.Key_Space: - self._change_selection_state(None) - return True - - if event.key() == QtCore.Qt.Key_Backspace: - self._change_selection_state(False) - return True - - if event.key() == QtCore.Qt.Key_Return: - self._change_selection_state(True) - return True - - return super(ProductTypesView, self).event(event) diff --git a/client/ayon_core/tools/loader/ui/products_delegates.py b/client/ayon_core/tools/loader/ui/products_delegates.py index fba9b5b3ca..8cece4687f 100644 --- a/client/ayon_core/tools/loader/ui/products_delegates.py +++ b/client/ayon_core/tools/loader/ui/products_delegates.py @@ -19,6 +19,7 @@ from .products_model import ( ) STATUS_NAME_ROLE = QtCore.Qt.UserRole + 1 +TASK_ID_ROLE = QtCore.Qt.UserRole + 2 class VersionsModel(QtGui.QStandardItemModel): @@ -48,6 +49,7 @@ class VersionsModel(QtGui.QStandardItemModel): item.setData(version_id, QtCore.Qt.UserRole) self._items_by_id[version_id] = item item.setData(version_item.status, STATUS_NAME_ROLE) + item.setData(version_item.task_id, TASK_ID_ROLE) if item.row() != idx: root_item.insertRow(idx, item) @@ -57,17 +59,30 @@ class VersionsFilterModel(QtCore.QSortFilterProxyModel): def __init__(self): super().__init__() self._status_filter = None + self._task_ids_filter = None def filterAcceptsRow(self, row, parent): - if self._status_filter is None: - return True + if self._status_filter is not None: + if not self._status_filter: + return False - if not self._status_filter: - return False + index = self.sourceModel().index(row, 0, parent) + status = index.data(STATUS_NAME_ROLE) + if status not in self._status_filter: + return False - index = self.sourceModel().index(row, 0, parent) - status = index.data(STATUS_NAME_ROLE) - return status in self._status_filter + if self._task_ids_filter: + index = self.sourceModel().index(row, 0, parent) + task_id = index.data(TASK_ID_ROLE) + if task_id not in self._task_ids_filter: + return False + return True + + def set_tasks_filter(self, task_ids): + if self._task_ids_filter == task_ids: + return + self._task_ids_filter = task_ids + self.invalidateFilter() def set_statuses_filter(self, status_names): if self._status_filter == status_names: @@ -101,6 +116,13 @@ class VersionComboBox(QtWidgets.QComboBox): def get_product_id(self): return self._product_id + def set_tasks_filter(self, task_ids): + self._proxy_model.set_tasks_filter(task_ids) + if self.count() == 0: + return + if self.currentIndex() != 0: + self.setCurrentIndex(0) + def set_statuses_filter(self, status_names): self._proxy_model.set_statuses_filter(status_names) if self.count() == 0: @@ -149,6 +171,7 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): super().__init__(*args, **kwargs) self._editor_by_id: Dict[str, VersionComboBox] = {} + self._task_ids_filter = None self._statuses_filter = None def displayText(self, value, locale): @@ -156,6 +179,11 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): return "N/A" return format_version(value) + def set_tasks_filter(self, task_ids): + self._task_ids_filter = set(task_ids) + for widget in self._editor_by_id.values(): + widget.set_tasks_filter(task_ids) + def set_statuses_filter(self, status_names): self._statuses_filter = set(status_names) for widget in self._editor_by_id.values(): @@ -239,6 +267,7 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): version_id = index.data(VERSION_ID_ROLE) editor.update_versions(versions, version_id) + editor.set_tasks_filter(self._task_ids_filter) editor.set_statuses_filter(self._statuses_filter) def setModelData(self, editor, model, index): diff --git a/client/ayon_core/tools/loader/ui/products_model.py b/client/ayon_core/tools/loader/ui/products_model.py index 3571788134..cebae9bca7 100644 --- a/client/ayon_core/tools/loader/ui/products_model.py +++ b/client/ayon_core/tools/loader/ui/products_model.py @@ -12,34 +12,35 @@ GROUP_TYPE_ROLE = QtCore.Qt.UserRole + 1 MERGED_COLOR_ROLE = QtCore.Qt.UserRole + 2 FOLDER_LABEL_ROLE = QtCore.Qt.UserRole + 3 FOLDER_ID_ROLE = QtCore.Qt.UserRole + 4 -PRODUCT_ID_ROLE = QtCore.Qt.UserRole + 5 -PRODUCT_NAME_ROLE = QtCore.Qt.UserRole + 6 -PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 7 -PRODUCT_TYPE_ICON_ROLE = QtCore.Qt.UserRole + 8 -PRODUCT_IN_SCENE_ROLE = QtCore.Qt.UserRole + 9 -VERSION_ID_ROLE = QtCore.Qt.UserRole + 10 -VERSION_HERO_ROLE = QtCore.Qt.UserRole + 11 -VERSION_NAME_ROLE = QtCore.Qt.UserRole + 12 -VERSION_NAME_EDIT_ROLE = QtCore.Qt.UserRole + 13 -VERSION_PUBLISH_TIME_ROLE = QtCore.Qt.UserRole + 14 -VERSION_STATUS_NAME_ROLE = QtCore.Qt.UserRole + 15 -VERSION_STATUS_SHORT_ROLE = QtCore.Qt.UserRole + 16 -VERSION_STATUS_COLOR_ROLE = QtCore.Qt.UserRole + 17 -VERSION_STATUS_ICON_ROLE = QtCore.Qt.UserRole + 18 -VERSION_AUTHOR_ROLE = QtCore.Qt.UserRole + 19 -VERSION_FRAME_RANGE_ROLE = QtCore.Qt.UserRole + 20 -VERSION_DURATION_ROLE = QtCore.Qt.UserRole + 21 -VERSION_HANDLES_ROLE = QtCore.Qt.UserRole + 22 -VERSION_STEP_ROLE = QtCore.Qt.UserRole + 23 -VERSION_AVAILABLE_ROLE = QtCore.Qt.UserRole + 24 -VERSION_THUMBNAIL_ID_ROLE = QtCore.Qt.UserRole + 25 -ACTIVE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 26 -REMOTE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 27 -REPRESENTATIONS_COUNT_ROLE = QtCore.Qt.UserRole + 28 -SYNC_ACTIVE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 29 -SYNC_REMOTE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 30 +TASK_ID_ROLE = QtCore.Qt.UserRole + 5 +PRODUCT_ID_ROLE = QtCore.Qt.UserRole + 6 +PRODUCT_NAME_ROLE = QtCore.Qt.UserRole + 7 +PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 8 +PRODUCT_TYPE_ICON_ROLE = QtCore.Qt.UserRole + 9 +PRODUCT_IN_SCENE_ROLE = QtCore.Qt.UserRole + 10 +VERSION_ID_ROLE = QtCore.Qt.UserRole + 11 +VERSION_HERO_ROLE = QtCore.Qt.UserRole + 12 +VERSION_NAME_ROLE = QtCore.Qt.UserRole + 13 +VERSION_NAME_EDIT_ROLE = QtCore.Qt.UserRole + 14 +VERSION_PUBLISH_TIME_ROLE = QtCore.Qt.UserRole + 15 +VERSION_STATUS_NAME_ROLE = QtCore.Qt.UserRole + 16 +VERSION_STATUS_SHORT_ROLE = QtCore.Qt.UserRole + 17 +VERSION_STATUS_COLOR_ROLE = QtCore.Qt.UserRole + 18 +VERSION_STATUS_ICON_ROLE = QtCore.Qt.UserRole + 19 +VERSION_AUTHOR_ROLE = QtCore.Qt.UserRole + 20 +VERSION_FRAME_RANGE_ROLE = QtCore.Qt.UserRole + 21 +VERSION_DURATION_ROLE = QtCore.Qt.UserRole + 22 +VERSION_HANDLES_ROLE = QtCore.Qt.UserRole + 23 +VERSION_STEP_ROLE = QtCore.Qt.UserRole + 24 +VERSION_AVAILABLE_ROLE = QtCore.Qt.UserRole + 25 +VERSION_THUMBNAIL_ID_ROLE = QtCore.Qt.UserRole + 26 +ACTIVE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 27 +REMOTE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 28 +REPRESENTATIONS_COUNT_ROLE = QtCore.Qt.UserRole + 29 +SYNC_ACTIVE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 30 +SYNC_REMOTE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 31 -STATUS_NAME_FILTER_ROLE = QtCore.Qt.UserRole + 31 +STATUS_NAME_FILTER_ROLE = QtCore.Qt.UserRole + 32 class ProductsModel(QtGui.QStandardItemModel): @@ -368,6 +369,7 @@ class ProductsModel(QtGui.QStandardItemModel): """ model_item.setData(version_item.version_id, VERSION_ID_ROLE) + model_item.setData(version_item.task_id, TASK_ID_ROLE) model_item.setData(version_item.version, VERSION_NAME_ROLE) model_item.setData(version_item.is_hero, VERSION_HERO_ROLE) model_item.setData( diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index 748a1b5fb8..94d95b9026 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -1,4 +1,6 @@ +from __future__ import annotations import collections +from typing import Optional from qtpy import QtWidgets, QtCore @@ -15,6 +17,7 @@ from .products_model import ( GROUP_TYPE_ROLE, MERGED_COLOR_ROLE, FOLDER_ID_ROLE, + TASK_ID_ROLE, PRODUCT_ID_ROLE, VERSION_ID_ROLE, VERSION_STATUS_NAME_ROLE, @@ -36,8 +39,9 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): def __init__(self, parent=None): super().__init__(parent) - self._product_type_filters = {} + self._product_type_filters = None self._statuses_filter = None + self._task_ids_filter = None self._ascending_sort = True def get_statuses_filter(self): @@ -45,7 +49,15 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): return None return set(self._statuses_filter) + def set_tasks_filter(self, task_ids_filter): + if self._task_ids_filter == task_ids_filter: + return + self._task_ids_filter = task_ids_filter + self.invalidateFilter() + def set_product_type_filters(self, product_type_filters): + if self._product_type_filters == product_type_filters: + return self._product_type_filters = product_type_filters self.invalidateFilter() @@ -58,29 +70,41 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): def filterAcceptsRow(self, source_row, source_parent): source_model = self.sourceModel() index = source_model.index(source_row, 0, source_parent) - - product_types_s = source_model.data(index, PRODUCT_TYPE_ROLE) - product_types = [] - if product_types_s: - product_types = product_types_s.split("|") - - for product_type in product_types: - if not self._product_type_filters.get(product_type, True): - return False - - if not self._accept_row_by_statuses(index): + if not self._accept_task_ids_filter(index): return False + + if not self._accept_row_by_role_value( + index, self._product_type_filters, PRODUCT_TYPE_ROLE + ): + return False + + if not self._accept_row_by_role_value( + index, self._statuses_filter, STATUS_NAME_FILTER_ROLE + ): + return False + return super().filterAcceptsRow(source_row, source_parent) - def _accept_row_by_statuses(self, index): - if self._statuses_filter is None: + def _accept_task_ids_filter(self, index): + if not self._task_ids_filter: return True - if not self._statuses_filter: + task_id = index.data(TASK_ID_ROLE) + return task_id in self._task_ids_filter + + def _accept_row_by_role_value( + self, + index: QtCore.QModelIndex, + filter_value: Optional[set[str]], + role: int + ): + if filter_value is None: + return True + if not filter_value: return False - status_s = index.data(STATUS_NAME_FILTER_ROLE) + status_s = index.data(role) for status in status_s.split("|"): - if status in self._statuses_filter: + if status in filter_value: return True return False @@ -120,7 +144,7 @@ class ProductsWidget(QtWidgets.QWidget): 90, # Product type 130, # Folder label 60, # Version - 100, # Status + 100, # Status 125, # Time 75, # Author 75, # Frames @@ -246,6 +270,16 @@ class ProductsWidget(QtWidgets.QWidget): """ self._products_proxy_model.setFilterFixedString(name) + def set_tasks_filter(self, task_ids): + """Set filter of version tasks. + + Args: + task_ids (set[str]): Task ids. + + """ + self._version_delegate.set_tasks_filter(task_ids) + self._products_proxy_model.set_tasks_filter(task_ids) + def set_statuses_filter(self, status_names): """Set filter of version statuses. diff --git a/client/ayon_core/tools/loader/ui/statuses_combo.py b/client/ayon_core/tools/loader/ui/statuses_combo.py index 9fe7ab62a5..2f034d00de 100644 --- a/client/ayon_core/tools/loader/ui/statuses_combo.py +++ b/client/ayon_core/tools/loader/ui/statuses_combo.py @@ -1,4 +1,4 @@ -from typing import List, Dict +from __future__ import annotations from qtpy import QtCore, QtGui @@ -7,7 +7,7 @@ from ayon_core.tools.common_models import StatusItem from ._multicombobox import ( CustomPaintMultiselectComboBox, - STANDARD_ITEM_TYPE, + BaseQtModel, ) STATUS_ITEM_TYPE = 0 @@ -24,62 +24,43 @@ ITEM_TYPE_ROLE = QtCore.Qt.UserRole + 5 ITEM_SUBTYPE_ROLE = QtCore.Qt.UserRole + 6 -class StatusesQtModel(QtGui.QStandardItemModel): +class StatusesQtModel(BaseQtModel): def __init__(self, controller): - self._controller = controller - self._items_by_name: Dict[str, QtGui.QStandardItem] = {} - self._icons_by_name_n_color: Dict[str, QtGui.QIcon] = {} - self._last_project = None + self._items_by_name: dict[str, QtGui.QStandardItem] = {} + self._icons_by_name_n_color: dict[str, QtGui.QIcon] = {} + super().__init__( + ITEM_TYPE_ROLE, + ITEM_SUBTYPE_ROLE, + "No statuses...", + controller, + ) - self._select_project_item = None - self._empty_statuses_item = None + def _get_standard_items(self) -> list[QtGui.QStandardItem]: + return list(self._items_by_name.values()) - self._select_all_item = None - self._deselect_all_item = None - self._swap_states_item = None + def _clear_standard_items(self): + self._items_by_name.clear() - super().__init__() - - self.refresh(None) - - def get_placeholder_text(self): - return self._placeholder - - def refresh(self, project_name): - # New project was selected - # status filter is reset to show all statuses - uncheck_all = False - if project_name != self._last_project: - self._last_project = project_name - uncheck_all = True - - if project_name is None: - self._add_select_project_item() - return - - status_items: List[StatusItem] = ( + def _prepare_new_value_items( + self, project_name: str, project_changed: bool + ): + status_items: list[StatusItem] = ( self._controller.get_project_status_items( project_name, sender=STATUSES_FILTER_SENDER ) ) + items = [] + items_to_remove = [] if not status_items: - self._add_empty_statuses_item() - return + return items, items_to_remove - self._remove_empty_items() - - items_to_remove = set(self._items_by_name) - root_item = self.invisibleRootItem() + names_to_remove = set(self._items_by_name) for row_idx, status_item in enumerate(status_items): name = status_item.name if name in self._items_by_name: - is_new = False item = self._items_by_name[name] - if uncheck_all: - item.setCheckState(QtCore.Qt.Unchecked) - items_to_remove.discard(name) + names_to_remove.discard(name) else: - is_new = True item = QtGui.QStandardItem() item.setData(ITEM_SUBTYPE_ROLE, STATUS_ITEM_TYPE) item.setCheckState(QtCore.Qt.Unchecked) @@ -100,36 +81,14 @@ class StatusesQtModel(QtGui.QStandardItemModel): if item.data(role) != value: item.setData(value, role) - if is_new: - root_item.insertRow(row_idx, item) + if project_changed: + item.setCheckState(QtCore.Qt.Unchecked) + items.append(item) - for name in items_to_remove: - item = self._items_by_name.pop(name) - root_item.removeRow(item.row()) + for name in names_to_remove: + items_to_remove.append(self._items_by_name.pop(name)) - self._add_selection_items() - - def setData(self, index, value, role): - if role == QtCore.Qt.CheckStateRole and index.isValid(): - item_type = index.data(ITEM_SUBTYPE_ROLE) - if item_type == SELECT_ALL_TYPE: - for item in self._items_by_name.values(): - item.setCheckState(QtCore.Qt.Checked) - return True - if item_type == DESELECT_ALL_TYPE: - for item in self._items_by_name.values(): - item.setCheckState(QtCore.Qt.Unchecked) - return True - if item_type == SWAP_STATE_TYPE: - for item in self._items_by_name.values(): - current_state = item.checkState() - item.setCheckState( - QtCore.Qt.Checked - if current_state == QtCore.Qt.Unchecked - else QtCore.Qt.Unchecked - ) - return True - return super().setData(index, value, role) + return items, items_to_remove def _get_icon(self, status_item: StatusItem) -> QtGui.QIcon: name = status_item.name @@ -147,139 +106,6 @@ class StatusesQtModel(QtGui.QStandardItemModel): self._icons_by_name_n_color[unique_id] = icon return icon - def _init_default_items(self): - if self._empty_statuses_item is not None: - return - - empty_statuses_item = QtGui.QStandardItem("No statuses...") - select_project_item = QtGui.QStandardItem("Select project...") - - select_all_item = QtGui.QStandardItem("Select all") - deselect_all_item = QtGui.QStandardItem("Deselect all") - swap_states_item = QtGui.QStandardItem("Swap") - - for item in ( - empty_statuses_item, - select_project_item, - select_all_item, - deselect_all_item, - swap_states_item, - ): - item.setData(STANDARD_ITEM_TYPE, ITEM_TYPE_ROLE) - - select_all_item.setIcon(get_qt_icon({ - "type": "material-symbols", - "name": "done_all", - "color": "white" - })) - deselect_all_item.setIcon(get_qt_icon({ - "type": "material-symbols", - "name": "remove_done", - "color": "white" - })) - swap_states_item.setIcon(get_qt_icon({ - "type": "material-symbols", - "name": "swap_horiz", - "color": "white" - })) - - for item in ( - empty_statuses_item, - select_project_item, - ): - item.setFlags(QtCore.Qt.NoItemFlags) - - for item, item_type in ( - (select_all_item, SELECT_ALL_TYPE), - (deselect_all_item, DESELECT_ALL_TYPE), - (swap_states_item, SWAP_STATE_TYPE), - ): - item.setData(item_type, ITEM_SUBTYPE_ROLE) - - for item in ( - select_all_item, - deselect_all_item, - swap_states_item, - ): - item.setFlags( - QtCore.Qt.ItemIsEnabled - | QtCore.Qt.ItemIsSelectable - | QtCore.Qt.ItemIsUserCheckable - ) - - self._empty_statuses_item = empty_statuses_item - self._select_project_item = select_project_item - - self._select_all_item = select_all_item - self._deselect_all_item = deselect_all_item - self._swap_states_item = swap_states_item - - def _get_empty_statuses_item(self): - self._init_default_items() - return self._empty_statuses_item - - def _get_select_project_item(self): - self._init_default_items() - return self._select_project_item - - def _get_empty_items(self): - self._init_default_items() - return [ - self._empty_statuses_item, - self._select_project_item, - ] - - def _get_selection_items(self): - self._init_default_items() - return [ - self._select_all_item, - self._deselect_all_item, - self._swap_states_item, - ] - - def _get_default_items(self): - return self._get_empty_items() + self._get_selection_items() - - def _add_select_project_item(self): - item = self._get_select_project_item() - if item.row() < 0: - self._remove_items() - root_item = self.invisibleRootItem() - root_item.appendRow(item) - - def _add_empty_statuses_item(self): - item = self._get_empty_statuses_item() - if item.row() < 0: - self._remove_items() - root_item = self.invisibleRootItem() - root_item.appendRow(item) - - def _add_selection_items(self): - root_item = self.invisibleRootItem() - items = self._get_selection_items() - for item in self._get_selection_items(): - row = item.row() - if row >= 0: - root_item.takeRow(row) - root_item.appendRows(items) - - def _remove_items(self): - root_item = self.invisibleRootItem() - for item in self._get_default_items(): - if item.row() < 0: - continue - root_item.takeRow(item.row()) - - root_item.removeRows(0, root_item.rowCount()) - self._items_by_name.clear() - - def _remove_empty_items(self): - root_item = self.invisibleRootItem() - for item in self._get_empty_items(): - if item.row() < 0: - continue - root_item.takeRow(item.row()) - class StatusesCombobox(CustomPaintMultiselectComboBox): def __init__(self, controller, parent): diff --git a/client/ayon_core/tools/loader/ui/tasks_widget.py b/client/ayon_core/tools/loader/ui/tasks_widget.py new file mode 100644 index 0000000000..5779fc2a01 --- /dev/null +++ b/client/ayon_core/tools/loader/ui/tasks_widget.py @@ -0,0 +1,405 @@ +import collections +import hashlib + +from qtpy import QtWidgets, QtCore, QtGui + +from ayon_core.style import get_default_entity_icon_color +from ayon_core.tools.utils import ( + RecursiveSortFilterProxyModel, + DeselectableTreeView, + TasksQtModel, + TASKS_MODEL_SENDER_NAME, +) +from ayon_core.tools.utils.tasks_widget import ( + ITEM_ID_ROLE, + ITEM_NAME_ROLE, + PARENT_ID_ROLE, + TASK_TYPE_ROLE, +) +from ayon_core.tools.utils.lib import RefreshThread, get_qt_icon + +# Role that can't clash with default 'tasks_widget' roles +FOLDER_LABEL_ROLE = QtCore.Qt.UserRole + 100 +NO_TASKS_ID = "--no-task--" + + +class LoaderTasksQtModel(TasksQtModel): + column_labels = [ + "Task name", + "Task type", + "Folder" + ] + + def __init__(self, controller): + super().__init__(controller) + + self._items_by_id = {} + self._groups_by_name = {} + self._last_folder_ids = set() + # This item is used to be able filter versions without any task + # - do not mismatch with '_empty_tasks_item' item from 'TasksQtModel' + self._no_tasks_item = None + + def refresh(self): + """Refresh tasks for selected folders.""" + + self._refresh(self._last_project_name, self._last_folder_ids) + + def set_context(self, project_name, folder_ids): + self._refresh(project_name, folder_ids) + + # Mark some functions from 'TasksQtModel' as not implemented + def get_index_by_name(self, task_name): + raise NotImplementedError( + "Method 'get_index_by_name' is not implemented." + ) + + def get_last_folder_id(self): + raise NotImplementedError( + "Method 'get_last_folder_id' is not implemented." + ) + + def flags(self, index): + if index.column() != 0: + index = self.index(index.row(), 0, index.parent()) + return super().flags(index) + + def _get_no_tasks_item(self): + if self._no_tasks_item is None: + item = QtGui.QStandardItem("No task") + icon = get_qt_icon({ + "type": "material-symbols", + "name": "indeterminate_check_box", + "color": get_default_entity_icon_color(), + }) + item.setData(icon, QtCore.Qt.DecorationRole) + item.setData(NO_TASKS_ID, ITEM_ID_ROLE) + item.setEditable(False) + self._no_tasks_item = item + return self._no_tasks_item + + def _refresh(self, project_name, folder_ids): + self._is_refreshing = True + self._last_project_name = project_name + self._last_folder_ids = folder_ids + if not folder_ids: + self._add_invalid_selection_item() + self._current_refresh_thread = None + self._is_refreshing = False + self.refreshed.emit() + return + + thread_id = hashlib.sha256( + "|".join(sorted(folder_ids)).encode() + ).hexdigest() + thread = self._refresh_threads.get(thread_id) + if thread is not None: + self._current_refresh_thread = thread + return + thread = RefreshThread( + thread_id, + self._thread_getter, + project_name, + folder_ids + ) + self._current_refresh_thread = thread + self._refresh_threads[thread.id] = thread + thread.refresh_finished.connect(self._on_refresh_thread) + thread.start() + + def _thread_getter(self, project_name, folder_ids): + task_items = self._controller.get_task_items( + project_name, folder_ids, sender=TASKS_MODEL_SENDER_NAME + ) + task_type_items = {} + if hasattr(self._controller, "get_task_type_items"): + task_type_items = self._controller.get_task_type_items( + project_name, sender=TASKS_MODEL_SENDER_NAME + ) + folder_ids = { + task_item.parent_id + for task_item in task_items + } + folder_labels_by_id = self._controller.get_folder_labels( + project_name, folder_ids + ) + return task_items, task_type_items, folder_labels_by_id + + def _on_refresh_thread(self, thread_id): + """Callback when refresh thread is finished. + + Technically can be running multiple refresh threads at the same time, + to avoid using values from wrong thread, we check if thread id is + current refresh thread id. + + Tasks are stored by name, so if a folder has same task name as + previously selected folder it keeps the selection. + + Args: + thread_id (str): Thread id. + """ + + # Make sure to remove thread from '_refresh_threads' dict + thread = self._refresh_threads.pop(thread_id) + if ( + self._current_refresh_thread is None + or thread_id != self._current_refresh_thread.id + ): + return + + self._fill_data_from_thread(thread) + + root_item = self.invisibleRootItem() + self._has_content = root_item.rowCount() > 0 + self._current_refresh_thread = None + self._is_refreshing = False + self.refreshed.emit() + + def _clear_items(self): + self._items_by_id = {} + self._groups_by_name = {} + super()._clear_items() + + def _fill_data_from_thread(self, thread): + task_items, task_type_items, folder_labels_by_id = thread.get_result() + # Task items are refreshed + if task_items is None: + return + + # No tasks are available on folder + if not task_items: + self._add_empty_task_item() + return + self._remove_invalid_items() + + task_type_item_by_name = { + task_type_item.name: task_type_item + for task_type_item in task_type_items + } + task_type_icon_cache = {} + current_ids = set() + items_by_name = collections.defaultdict(list) + for task_item in task_items: + task_id = task_item.task_id + current_ids.add(task_id) + item = self._items_by_id.get(task_id) + if item is None: + item = QtGui.QStandardItem() + item.setColumnCount(self.columnCount()) + item.setEditable(False) + self._items_by_id[task_id] = item + + icon = self._get_task_item_icon( + task_item, + task_type_item_by_name, + task_type_icon_cache + ) + name = task_item.name + folder_id = task_item.parent_id + folder_label = folder_labels_by_id.get(folder_id) + + item.setData(name, QtCore.Qt.DisplayRole) + item.setData(name, ITEM_NAME_ROLE) + item.setData(task_item.id, ITEM_ID_ROLE) + item.setData(task_item.task_type, TASK_TYPE_ROLE) + item.setData(folder_id, PARENT_ID_ROLE) + item.setData(folder_label, FOLDER_LABEL_ROLE) + item.setData(icon, QtCore.Qt.DecorationRole) + + items_by_name[name].append(item) + + root_item = self.invisibleRootItem() + + # Make sure item is not parented + # - this is laziness to avoid re-parenting items which does + # complicate the code with no benefit + queue = collections.deque() + queue.append((None, root_item)) + while queue: + (parent, item) = queue.popleft() + if not item.hasChildren(): + if parent: + parent.takeRow(item.row()) + continue + + for row in range(item.rowCount()): + queue.append((item, item.child(row, 0))) + + queue.append((parent, item)) + + used_group_names = set() + new_root_items = [ + self._get_no_tasks_item() + ] + for name, items in items_by_name.items(): + if len(items) == 1: + new_root_items.extend(items) + continue + + used_group_names.add(name) + group_item = self._groups_by_name.get(name) + if group_item is None: + group_item = QtGui.QStandardItem() + group_item.setData(name, QtCore.Qt.DisplayRole) + group_item.setEditable(False) + group_item.setColumnCount(self.columnCount()) + self._groups_by_name[name] = group_item + + # Use icon from first item + first_item_icon = items[0].data(QtCore.Qt.DecorationRole) + task_ids = [ + item.data(ITEM_ID_ROLE) + for item in items + ] + + group_item.setData(first_item_icon, QtCore.Qt.DecorationRole) + group_item.setData("|".join(task_ids), ITEM_ID_ROLE) + + group_item.appendRows(items) + + new_root_items.append(group_item) + + # Remove unused caches + for task_id in set(self._items_by_id) - current_ids: + self._items_by_id.pop(task_id) + + for name in set(self._groups_by_name) - used_group_names: + self._groups_by_name.pop(name) + + if new_root_items: + root_item.appendRows(new_root_items) + + def data(self, index, role=None): + if not index.isValid(): + return None + + if role is None: + role = QtCore.Qt.DisplayRole + + col = index.column() + if col != 0: + index = self.index(index.row(), 0, index.parent()) + + if col == 1: + if role == QtCore.Qt.DisplayRole: + role = TASK_TYPE_ROLE + else: + return None + + if col == 2: + if role == QtCore.Qt.DisplayRole: + role = FOLDER_LABEL_ROLE + else: + return None + + return super().data(index, role) + + +class LoaderTasksProxyModel(RecursiveSortFilterProxyModel): + def lessThan(self, left, right): + if left.data(ITEM_ID_ROLE) == NO_TASKS_ID: + return False + if right.data(ITEM_ID_ROLE) == NO_TASKS_ID: + return True + return super().lessThan(left, right) + + +class LoaderTasksWidget(QtWidgets.QWidget): + refreshed = QtCore.Signal() + + def __init__(self, controller, parent): + super().__init__(parent) + + tasks_view = DeselectableTreeView(self) + tasks_view.setSelectionMode( + QtWidgets.QAbstractItemView.ExtendedSelection + ) + + tasks_model = LoaderTasksQtModel(controller) + tasks_proxy_model = LoaderTasksProxyModel() + tasks_proxy_model.setSourceModel(tasks_model) + tasks_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) + + tasks_view.setModel(tasks_proxy_model) + # Hide folder column by default + tasks_view.setColumnHidden(2, True) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(tasks_view, 1) + + controller.register_event_callback( + "selection.folders.changed", + self._on_folders_selection_changed, + ) + controller.register_event_callback( + "tasks.refresh.finished", + self._on_tasks_refresh_finished + ) + + selection_model = tasks_view.selectionModel() + selection_model.selectionChanged.connect(self._on_selection_change) + + tasks_model.refreshed.connect(self._on_model_refresh) + + self._controller = controller + self._tasks_view = tasks_view + self._tasks_model = tasks_model + self._tasks_proxy_model = tasks_proxy_model + + self._fisrt_show = True + + def showEvent(self, event): + super().showEvent(event) + if self._fisrt_show: + self._fisrt_show = False + header_widget = self._tasks_view.header() + header_widget.resizeSection(0, 200) + + def set_name_filter(self, name): + """Set filter of folder name. + + Args: + name (str): The string filter. + + """ + self._tasks_proxy_model.setFilterFixedString(name) + if name: + self._tasks_view.expandAll() + + def refresh(self): + self._tasks_model.refresh() + + def _clear(self): + self._tasks_model.clear() + + def _on_tasks_refresh_finished(self, event): + if event["sender"] != TASKS_MODEL_SENDER_NAME: + self._set_project_name(event["project_name"]) + + def _on_folders_selection_changed(self, event): + project_name = event["project_name"] + folder_ids = event["folder_ids"] + self._tasks_view.setColumnHidden(2, len(folder_ids) == 1) + self._tasks_model.set_context(project_name, folder_ids) + + def _on_model_refresh(self): + self._tasks_proxy_model.sort(0) + self.refreshed.emit() + + def _get_selected_item_ids(self): + selection_model = self._tasks_view.selectionModel() + item_ids = set() + for index in selection_model.selectedIndexes(): + item_id = index.data(ITEM_ID_ROLE) + if item_id is None: + continue + if item_id == NO_TASKS_ID: + item_ids.add(None) + else: + item_ids |= set(item_id.split("|")) + return item_ids + + def _on_selection_change(self): + item_ids = self._get_selected_item_ids() + self._controller.set_selected_tasks(item_ids) diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index 31c9908b23..b846484c39 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -14,8 +14,9 @@ from ayon_core.tools.utils import ProjectsCombobox from ayon_core.tools.loader.control import LoaderController from .folders_widget import LoaderFoldersWidget +from .tasks_widget import LoaderTasksWidget from .products_widget import ProductsWidget -from .product_types_widget import ProductTypesView +from .product_types_combo import ProductTypesCombobox from .product_group_dialog import ProductGroupDialog from .info_widget import InfoWidget from .repres_widget import RepresentationsWidget @@ -164,16 +165,16 @@ class LoaderWindow(QtWidgets.QWidget): folders_widget = LoaderFoldersWidget(controller, context_widget) - product_types_widget = ProductTypesView(controller, context_splitter) - context_layout = QtWidgets.QVBoxLayout(context_widget) context_layout.setContentsMargins(0, 0, 0, 0) context_layout.addWidget(context_top_widget, 0) context_layout.addWidget(folders_filter_input, 0) context_layout.addWidget(folders_widget, 1) + tasks_widget = LoaderTasksWidget(controller, context_widget) + context_splitter.addWidget(context_widget) - context_splitter.addWidget(product_types_widget) + context_splitter.addWidget(tasks_widget) context_splitter.setStretchFactor(0, 65) context_splitter.setStretchFactor(1, 35) @@ -185,6 +186,10 @@ class LoaderWindow(QtWidgets.QWidget): products_filter_input = PlaceholderLineEdit(products_inputs_widget) products_filter_input.setPlaceholderText("Product name filter...") + product_types_filter_combo = ProductTypesCombobox( + controller, products_inputs_widget + ) + product_status_filter_combo = StatusesCombobox(controller, self) product_group_checkbox = QtWidgets.QCheckBox( @@ -196,6 +201,7 @@ class LoaderWindow(QtWidgets.QWidget): products_inputs_layout = QtWidgets.QHBoxLayout(products_inputs_widget) products_inputs_layout.setContentsMargins(0, 0, 0, 0) products_inputs_layout.addWidget(products_filter_input, 1) + products_inputs_layout.addWidget(product_types_filter_combo, 1) products_inputs_layout.addWidget(product_status_filter_combo, 1) products_inputs_layout.addWidget(product_group_checkbox, 0) @@ -244,12 +250,12 @@ class LoaderWindow(QtWidgets.QWidget): folders_filter_input.textChanged.connect( self._on_folder_filter_change ) - product_types_widget.filter_changed.connect( - self._on_product_type_filter_change - ) products_filter_input.textChanged.connect( self._on_product_filter_change ) + product_types_filter_combo.value_changed.connect( + self._on_product_type_filter_change + ) product_status_filter_combo.value_changed.connect( self._on_status_filter_change ) @@ -280,6 +286,10 @@ class LoaderWindow(QtWidgets.QWidget): "selection.folders.changed", self._on_folders_selection_changed, ) + controller.register_event_callback( + "selection.tasks.changed", + self._on_tasks_selection_change, + ) controller.register_event_callback( "selection.versions.changed", self._on_versions_selection_changed, @@ -304,9 +314,10 @@ class LoaderWindow(QtWidgets.QWidget): self._folders_filter_input = folders_filter_input self._folders_widget = folders_widget - self._product_types_widget = product_types_widget + self._tasks_widget = tasks_widget self._products_filter_input = products_filter_input + self._product_types_filter_combo = product_types_filter_combo self._product_status_filter_combo = product_status_filter_combo self._product_group_checkbox = product_group_checkbox self._products_widget = products_widget @@ -335,7 +346,7 @@ class LoaderWindow(QtWidgets.QWidget): self._controller.reset() def showEvent(self, event): - super(LoaderWindow, self).showEvent(event) + super().showEvent(event) if self._first_show: self._on_first_show() @@ -343,9 +354,13 @@ class LoaderWindow(QtWidgets.QWidget): self._show_timer.start() def closeEvent(self, event): - super(LoaderWindow, self).closeEvent(event) + super().closeEvent(event) - self._product_types_widget.reset_product_types_filter_on_refresh() + ( + self + ._product_types_filter_combo + .reset_product_types_filter_on_refresh() + ) self._reset_on_show = True @@ -363,7 +378,7 @@ class LoaderWindow(QtWidgets.QWidget): event.setAccepted(True) return - super(LoaderWindow, self).keyPressEvent(event) + super().keyPressEvent(event) def _on_first_show(self): self._first_show = False @@ -423,14 +438,16 @@ class LoaderWindow(QtWidgets.QWidget): def _on_product_filter_change(self, text): self._products_widget.set_name_filter(text) + def _on_tasks_selection_change(self, event): + self._products_widget.set_tasks_filter(event["task_ids"]) + def _on_status_filter_change(self): status_names = self._product_status_filter_combo.get_value() self._products_widget.set_statuses_filter(status_names) def _on_product_type_filter_change(self): - self._products_widget.set_product_type_filter( - self._product_types_widget.get_filter_info() - ) + product_types = self._product_types_filter_combo.get_value() + self._products_widget.set_product_type_filter(product_types) def _on_merged_products_selection_change(self): items = self._products_widget.get_selected_merged_products() diff --git a/client/ayon_core/tools/utils/host_tools.py b/client/ayon_core/tools/utils/host_tools.py index 1eff746b9e..3d356555f3 100644 --- a/client/ayon_core/tools/utils/host_tools.py +++ b/client/ayon_core/tools/utils/host_tools.py @@ -7,7 +7,7 @@ import os import pyblish.api -from ayon_core.host import ILoadHost +from ayon_core.host import ILoadHost, IPublishHost from ayon_core.lib import Logger from ayon_core.pipeline import registered_host @@ -236,7 +236,7 @@ class HostToolsHelper: from ayon_core.tools.publisher.window import PublisherWindow host = registered_host() - ILoadHost.validate_load_methods(host) + IPublishHost.validate_publish_methods(host) publisher_window = PublisherWindow( controller=controller, diff --git a/client/ayon_core/tools/utils/tasks_widget.py b/client/ayon_core/tools/utils/tasks_widget.py index 87a4c3db3b..30846e6cda 100644 --- a/client/ayon_core/tools/utils/tasks_widget.py +++ b/client/ayon_core/tools/utils/tasks_widget.py @@ -24,9 +24,14 @@ class TasksQtModel(QtGui.QStandardItemModel): """ _default_task_icon = None refreshed = QtCore.Signal() + column_labels = ["Tasks"] def __init__(self, controller): - super(TasksQtModel, self).__init__() + super().__init__() + + self.setColumnCount(len(self.column_labels)) + for idx, label in enumerate(self.column_labels): + self.setHeaderData(idx, QtCore.Qt.Horizontal, label) self._controller = controller @@ -53,7 +58,8 @@ class TasksQtModel(QtGui.QStandardItemModel): self._has_content = False self._remove_invalid_items() root_item = self.invisibleRootItem() - root_item.removeRows(0, root_item.rowCount()) + while root_item.rowCount() != 0: + root_item.takeRow(0) def refresh(self): """Refresh tasks for last project and folder.""" @@ -336,19 +342,6 @@ class TasksQtModel(QtGui.QStandardItemModel): return self._has_content - def headerData(self, section, orientation, role): - # Show nice labels in the header - if ( - role == QtCore.Qt.DisplayRole - and orientation == QtCore.Qt.Horizontal - ): - if section == 0: - return "Tasks" - - return super(TasksQtModel, self).headerData( - section, orientation, role - ) - class TasksWidget(QtWidgets.QWidget): """Tasks widget. @@ -365,7 +358,7 @@ class TasksWidget(QtWidgets.QWidget): selection_changed = QtCore.Signal() def __init__(self, controller, parent, handle_expected_selection=False): - super(TasksWidget, self).__init__(parent) + super().__init__(parent) tasks_view = DeselectableTreeView(self) tasks_view.setIndentation(0) diff --git a/client/ayon_core/tools/utils/views.py b/client/ayon_core/tools/utils/views.py index d8ae94bf0c..d69be9b6a9 100644 --- a/client/ayon_core/tools/utils/views.py +++ b/client/ayon_core/tools/utils/views.py @@ -7,7 +7,6 @@ class DeselectableTreeView(QtWidgets.QTreeView): """A tree view that deselects on clicking on an empty area in the view""" def mousePressEvent(self, event): - index = self.indexAt(event.pos()) if not index.isValid(): # clear the selection @@ -15,7 +14,14 @@ class DeselectableTreeView(QtWidgets.QTreeView): # clear the current index self.setCurrentIndex(QtCore.QModelIndex()) - QtWidgets.QTreeView.mousePressEvent(self, event) + elif ( + self.selectionModel().isSelected(index) + and len(self.selectionModel().selectedRows()) == 1 + and event.modifiers() == QtCore.Qt.NoModifier + ): + event.setModifiers(QtCore.Qt.ControlModifier) + + super().mousePressEvent(event) class TreeView(QtWidgets.QTreeView):