From ceef6876e92691cb435031db8092cb6b14f8ee6c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 10 Jun 2025 17:27:27 +0200 Subject: [PATCH 01/59] base implementation of search bar --- client/ayon_core/style/style.css | 40 ++ .../ayon_core/tools/loader/ui/search_bar.py | 637 ++++++++++++++++++ client/ayon_core/tools/loader/ui/window.py | 150 +++-- 3 files changed, 786 insertions(+), 41 deletions(-) create mode 100644 client/ayon_core/tools/loader/ui/search_bar.py diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index 0e19702d53..9270ddee30 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -862,6 +862,46 @@ HintedLineEditButton { border-radius: 0.1em; } +/* Launcher specific stylesheets */ +FiltersBar { + background: {color:bg-inputs}; + border-radius: 5px; +} + +FiltersBar #SearchButton { + background: transparent; +} + +FiltersPopup #PopupWrapper, FilterValuePopup #PopupWrapper { + border-radius: 5px; + background: {color:bg-inputs}; +} + +FilterItemButton, FilterValueItemWidget { + border-radius: 5px; + background: transparent; +} +FilterItemButton:hover, FilterValueItemWidget:hover { + background: {color:bg-buttons-hover}; +} +FilterValueItemWidget[selected="1"] { + background: {color:bg-view-selection}; +} +FilterValueItemWidget[selected="1"]:hover { + background: {color:bg-view-selection-hover}; +} +SearchItemDisplayWidget { + border-radius: 5px; +} +SearchItemDisplayWidget #CloseButton { + background: transparent; + border-radius: 5px; +} +SearchItemDisplayWidget #ValueWidget { + border-radius: 3px; + background: {color:bg-buttons}; +} + /* Subset Manager */ #SubsetManagerDetailsText {} #SubsetManagerDetailsText[state="invalid"] { 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..72c16e5566 --- /dev/null +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -0,0 +1,637 @@ +import copy +import uuid +from dataclasses import dataclass +from typing import Any, Optional + +from qtpy import QtCore, QtWidgets + +from ayon_core.style import load_stylesheet +from ayon_core.tools.utils import ( + get_qt_icon, + SquareButton, + BaseClickableFrame, + ClickableFrame, + PixmapLabel, +) + + +@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 SearchItemDisplayWidget(QtWidgets.QFrame): + 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 + + close_icon = get_qt_icon({ + "type": "material-symbols", + "name": "close", + "color": "#FFFFFF", + }) + + title_widget = QtWidgets.QLabel(f"{filter_def.title}:", self) + + value_wrapper = QtWidgets.QWidget(self) + value_widget = QtWidgets.QLabel(value_wrapper) + value_widget.setObjectName("ValueWidget") + value_widget.setText(6 * " ") + value_layout = QtWidgets.QVBoxLayout(value_wrapper) + value_layout.setContentsMargins(2, 2, 2, 2) + value_layout.addWidget(value_widget) + + close_btn = SquareButton(self) + close_btn.setObjectName("CloseButton") + close_btn.setIcon(close_icon) + close_btn.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + + 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 = "" + if isinstance(value, str): + text = value + elif len(value) == 1: + text = value[0] + elif len(value) > 1: + text = str(len(value)) + + if len(text) > 9: + text = text[:9] + "..." + + text = " " + text + " " + text_diff = 4 - len(text) + if text_diff > 0: + text = " " * text_diff + 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 _request_edit(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) + title_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(5, 5, 5, 5) + main_layout.addWidget(title_widget, 1) + + 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) + + def __init__(self, parent): + super().__init__(parent) + self.setWindowFlags(QtCore.Qt.Popup | QtCore.Qt.FramelessWindowHint) + self.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + wrapper = QtWidgets.QWidget(self) + wrapper.setObjectName("PopupWrapper") + + wraper_layout = QtWidgets.QVBoxLayout(wrapper) + wraper_layout.setContentsMargins(5, 5, 5, 5) + wraper_layout.setSpacing(0) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(wrapper) + + self._wrapper = wrapper + self._wrapper_layout = wraper_layout + 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_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) + + if self._wrapper_layout.count() == 0: + empty_label = QtWidgets.QLabel( + "No filters available...", self._wrapper + ) + self._wrapper_layout.addWidget(empty_label) + + +class FilterValueItemWidget(BaseClickableFrame): + selected = QtCore.Signal(str) + + def __init__(self, widget_id, value, icon, color, parent): + super().__init__(parent) + + label_widget = QtWidgets.QLabel(str(value), self) + if color: + label_widget.setStyleSheet(f"color: {color};") + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(5, 5, 5, 5) + main_layout.addWidget(label_widget, 1) + + self._icon_widget = None + self._label_widget = label_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 FilterValueItemsView(QtWidgets.QWidget): + value_changed = QtCore.Signal() + + def __init__(self, parent): + super().__init__(parent) + self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + + self._multiselection = False + self._main_layout = main_layout + self._last_selected_widget = None + self._widgets_by_id = {} + + 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 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._main_layout.count() > 0: + item = self._main_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 + + for item in items: + widget_id = uuid.uuid4().hex + widget = FilterValueItemWidget( + 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._main_layout.addWidget(widget) + + 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) + + def __init__(self, parent): + super().__init__(parent) + self.setWindowFlags(QtCore.Qt.Popup | QtCore.Qt.FramelessWindowHint) + self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + + wrapper = QtWidgets.QWidget(self) + wrapper.setObjectName("PopupWrapper") + + text_input = QtWidgets.QLineEdit(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.addWidget(text_input, 0) + wraper_layout.addWidget(items_view, 0) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(wrapper) + + text_input.textChanged.connect(self._text_changed) + text_input.returnPressed.connect(self._text_confirmed) + + items_view.value_changed.connect(self._selection_changed) + + 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_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.setPlaceholderText( + filter_def.placeholder or "" + ) + self._text_input.setText(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() + + 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 get_value(self): + """Get the value from the active widget.""" + if self._active_widget is self._text_input: + return self._text_input.text() + 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 == self._text_input: + # Emit value changed signal if text input is active + self.value_changed.emit(self._filter_name) + + def _text_confirmed(self): + self.close() + + def _selection_changed(self): + self.value_changed.emit(self._filter_name) + + +class FiltersBar(ClickableFrame): + filters_changed = QtCore.Signal() + + 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") + + filters_widget = QtWidgets.QWidget(self) + 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_widget, 1) + + search_btn.clicked.connect(self._on_filters_request) + self.clicked.connect(self._on_clicked) + + self._search_btn = search_btn + self._filters_widget = filters_widget + self._filters_layout = filters_layout + self._widgets_by_name = {} + self._filter_defs_by_name = {} + self._filters_popup = FiltersPopup(self) + self._filter_value_popup = FilterValuePopup(self) + + 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 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.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 _on_clicked(self): + self._show_filters_popup() + + 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.set_filter_items(filter_defs) + filters_popup.set_preferred_width(self.width()) + + old_popup, self._filters_popup = self._filters_popup, filters_popup + + self._show_popup(filters_popup) + + old_popup.setVisible(False) + old_popup.deleteLater() + + def _on_filters_request(self): + self._show_filters_popup() + + 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) + + old_popup, self._filter_value_popup = ( + self._filter_value_popup, filter_value_popup + ) + + self._show_popup(filter_value_popup) + self._on_filter_value_change(filter_def.name) + + old_popup.setVisible(False) + old_popup.deleteLater() + + 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) + + 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_item_close_requested(self, name): + widget = self._widgets_by_name.pop(name, None) + if widget is not None: + idx = self._filters_layout.indexOf(widget) + if idx > -1: + self._filters_layout.takeAt(idx) + widget.setVisible(False) + widget.deleteLater() diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index b70f5554c7..580934f2b2 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -11,6 +11,8 @@ 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 @@ -21,6 +23,7 @@ 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 class LoadErrorMessageBox(ErrorMessageBox): @@ -182,32 +185,34 @@ 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) + # + # 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( "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(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) + products_wrap_layout.addWidget(search_bar, 0) products_wrap_layout.addWidget(products_widget, 1) right_panel_splitter = QtWidgets.QSplitter(main_splitter) @@ -250,15 +255,16 @@ 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.filters_changed.connect(self._on_filters_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 + # ) product_group_checkbox.stateChanged.connect( self._on_product_group_change ) @@ -316,9 +322,10 @@ 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._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 @@ -344,6 +351,7 @@ class LoaderWindow(QtWidgets.QWidget): def refresh(self): self._reset_on_show = False self._controller.reset() + self._update_filters() def showEvent(self, event): super().showEvent(event) @@ -356,11 +364,11 @@ class LoaderWindow(QtWidgets.QWidget): def closeEvent(self, event): super().closeEvent(event) - ( - self - ._product_types_filter_combo - .reset_product_types_filter_on_refresh() - ) + # ( + # self + # ._product_types_filter_combo + # .reset_product_types_filter_on_refresh() + # ) self._reset_on_show = True @@ -435,19 +443,22 @@ 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_filters_change(self): + pass 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_product_filter_change(self, text): + # self._products_widget.set_name_filter(text) + # + # 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() @@ -491,6 +502,63 @@ 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 + product_type_items: list[ProductTypeItem] = [] + status_items: list[StatusItem] = [] + if project_name: + product_type_items = self._controller.get_product_type_items( + project_name + ) + status_items = self._controller.get_project_status_items( + project_name + ) + + 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 + }, + "value": status_item.name, + } + for status_item in status_items + ] + + 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, + ), + ]) def _on_folders_selection_changed(self, event): self._selected_folder_ids = set(event["folder_ids"]) From 98fbeb96ec5cdc661d26565c12dbab4569df05a1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 10 Jun 2025 17:31:56 +0200 Subject: [PATCH 02/59] set status color to text --- client/ayon_core/tools/loader/ui/window.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index 580934f2b2..f236a1e3ae 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -530,6 +530,7 @@ class LoaderWindow(QtWidgets.QWidget): "name": status_item.icon, "color": status_item.color }, + "color": status_item.color, "value": status_item.name, } for status_item in status_items From 2c145ca30e52ea0884345598d581da144c679765 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 10:00:28 +0200 Subject: [PATCH 03/59] added scroll area to popups --- client/ayon_core/style/style.css | 18 ++++---- .../ayon_core/tools/loader/ui/search_bar.py | 43 +++++++++++++------ 2 files changed, 40 insertions(+), 21 deletions(-) diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index 9270ddee30..adf27a0e03 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -868,6 +868,9 @@ FiltersBar { border-radius: 5px; } +FiltersBar #ScrollArea { + background: {color:bg-inputs}; +} FiltersBar #SearchButton { background: transparent; } @@ -877,24 +880,23 @@ FiltersPopup #PopupWrapper, FilterValuePopup #PopupWrapper { background: {color:bg-inputs}; } -FilterItemButton, FilterValueItemWidget { +FilterItemButton, FilterValueItemButton { border-radius: 5px; background: transparent; } -FilterItemButton:hover, FilterValueItemWidget:hover { +FilterItemButton:hover, FilterValueItemButton:hover { background: {color:bg-buttons-hover}; } -FilterValueItemWidget[selected="1"] { +FilterValueItemButton[selected="1"] { background: {color:bg-view-selection}; } -FilterValueItemWidget[selected="1"]:hover { +FilterValueItemButton[selected="1"]:hover { background: {color:bg-view-selection-hover}; } -SearchItemDisplayWidget { - border-radius: 5px; +FilterValueItemsView #ContentWidget { + background: {color:bg-inputs}; } -SearchItemDisplayWidget #CloseButton { - background: transparent; +SearchItemDisplayWidget { border-radius: 5px; } SearchItemDisplayWidget #ValueWidget { diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index 72c16e5566..d1946d4ab3 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -124,12 +124,13 @@ class FilterItemButton(BaseClickableFrame): self._filter_def = filter_def title_widget = QtWidgets.QLabel(filter_def.title, self) - title_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) 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) @@ -148,7 +149,7 @@ class FiltersPopup(QtWidgets.QWidget): wraper_layout = QtWidgets.QVBoxLayout(wrapper) wraper_layout.setContentsMargins(5, 5, 5, 5) - wraper_layout.setSpacing(0) + wraper_layout.setSpacing(5) main_layout = QtWidgets.QHBoxLayout(self) main_layout.setContentsMargins(0, 0, 0, 0) @@ -187,22 +188,22 @@ class FiltersPopup(QtWidgets.QWidget): self._wrapper_layout.addWidget(empty_label) -class FilterValueItemWidget(BaseClickableFrame): +class FilterValueItemButton(BaseClickableFrame): selected = QtCore.Signal(str) def __init__(self, widget_id, value, icon, color, parent): super().__init__(parent) - label_widget = QtWidgets.QLabel(str(value), self) + title_widget = QtWidgets.QLabel(str(value), self) if color: - label_widget.setStyleSheet(f"color: {color};") + title_widget.setStyleSheet(f"color: {color};") main_layout = QtWidgets.QHBoxLayout(self) main_layout.setContentsMargins(5, 5, 5, 5) - main_layout.addWidget(label_widget, 1) + main_layout.addWidget(title_widget, 1) self._icon_widget = None - self._label_widget = label_widget + self._title_widget = title_widget self._main_layout = main_layout self._selected = False self._value = value @@ -245,13 +246,28 @@ class FilterValueItemsView(QtWidgets.QWidget): def __init__(self, parent): super().__init__(parent) - self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + + 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) main_layout = QtWidgets.QVBoxLayout(self) main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(scroll_area) self._multiselection = False - self._main_layout = main_layout + self._content_layout = content_layout self._last_selected_widget = None self._widgets_by_id = {} @@ -327,8 +343,8 @@ class FilterValueItemsView(QtWidgets.QWidget): return None def set_items(self, items: list[dict[str, Any]]): - while self._main_layout.count() > 0: - item = self._main_layout.takeAt(0) + while self._content_layout.count() > 0: + item = self._content_layout.takeAt(0) widget = item.widget() if widget is not None: widget.setVisible(False) @@ -338,7 +354,7 @@ class FilterValueItemsView(QtWidgets.QWidget): for item in items: widget_id = uuid.uuid4().hex - widget = FilterValueItemWidget( + widget = FilterValueItemButton( widget_id, item["value"], item.get("icon"), @@ -347,7 +363,7 @@ class FilterValueItemsView(QtWidgets.QWidget): ) widget.selected.connect(self._on_item_clicked) self._widgets_by_id[widget_id] = widget - self._main_layout.addWidget(widget) + self._content_layout.addWidget(widget) def _on_item_clicked(self, widget_id): widget = self._widgets_by_id.get(widget_id) @@ -385,6 +401,7 @@ class FilterValuePopup(QtWidgets.QWidget): 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) From 2845ac39b47f17bf5e544be81c090ed3cdd0ec5d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 10:00:49 +0200 Subject: [PATCH 04/59] enhanced close button --- .../ayon_core/tools/loader/ui/search_bar.py | 57 +++++++++++++++---- 1 file changed, 45 insertions(+), 12 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index d1946d4ab3..a9dea848bd 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -3,9 +3,9 @@ import uuid from dataclasses import dataclass from typing import Any, Optional -from qtpy import QtCore, QtWidgets +from qtpy import QtCore, QtWidgets, QtGui -from ayon_core.style import load_stylesheet +from ayon_core.style import load_stylesheet, get_objected_colors from ayon_core.tools.utils import ( get_qt_icon, SquareButton, @@ -34,7 +34,49 @@ class FilterDefinition: 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(QtWidgets.QFrame): + """Widget displaying a set filter in the bar.""" close_requested = QtCore.Signal(str) edit_requested = QtCore.Signal(str) @@ -47,12 +89,6 @@ class SearchItemDisplayWidget(QtWidgets.QFrame): self._filter_def = filter_def - close_icon = get_qt_icon({ - "type": "material-symbols", - "name": "close", - "color": "#FFFFFF", - }) - title_widget = QtWidgets.QLabel(f"{filter_def.title}:", self) value_wrapper = QtWidgets.QWidget(self) @@ -63,10 +99,7 @@ class SearchItemDisplayWidget(QtWidgets.QFrame): value_layout.setContentsMargins(2, 2, 2, 2) value_layout.addWidget(value_widget) - close_btn = SquareButton(self) - close_btn.setObjectName("CloseButton") - close_btn.setIcon(close_icon) - close_btn.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + close_btn = CloseButton(self) main_layout = QtWidgets.QHBoxLayout(self) main_layout.setContentsMargins(4, 0, 0, 0) From 02d26f9d2d7b3551ec125276d792874214eb2693 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 10:01:00 +0200 Subject: [PATCH 05/59] avoid fake spacing --- client/ayon_core/tools/loader/ui/search_bar.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index a9dea848bd..27d65bee30 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -94,7 +94,7 @@ class SearchItemDisplayWidget(QtWidgets.QFrame): value_wrapper = QtWidgets.QWidget(self) value_widget = QtWidgets.QLabel(value_wrapper) value_widget.setObjectName("ValueWidget") - value_widget.setText(6 * " ") + value_widget.setText("") value_layout = QtWidgets.QVBoxLayout(value_wrapper) value_layout.setContentsMargins(2, 2, 2, 2) value_layout.addWidget(value_widget) @@ -116,20 +116,19 @@ class SearchItemDisplayWidget(QtWidgets.QFrame): def set_value(self, value: "str | list[str]"): text = "" + ellide = True if isinstance(value, str): text = value elif len(value) == 1: text = value[0] elif len(value) > 1: - text = str(len(value)) + ellide = False + text = f"Items: {len(value)}" - if len(text) > 9: + if ellide and len(text) > 9: text = text[:9] + "..." text = " " + text + " " - text_diff = 4 - len(text) - if text_diff > 0: - text = " " * text_diff + text self._value = copy.deepcopy(value) self._value_widget.setText(text) From 2da85dd1f4ac5c07182d12f79e2d9243f5547d6a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 11:08:22 +0200 Subject: [PATCH 06/59] added shadow --- client/ayon_core/style/style.css | 5 +++ .../ayon_core/tools/loader/ui/search_bar.py | 43 +++++++++++++++++-- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index adf27a0e03..525cf28633 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -880,6 +880,11 @@ FiltersPopup #PopupWrapper, FilterValuePopup #PopupWrapper { 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; diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index 27d65bee30..ac0718f988 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -176,6 +176,9 @@ class FiltersPopup(QtWidgets.QWidget): self.setWindowFlags(QtCore.Qt.Popup | QtCore.Qt.FramelessWindowHint) self.setAttribute(QtCore.Qt.WA_TranslucentBackground) + shadow_frame = QtWidgets.QFrame(self) + shadow_frame.setObjectName("ShadowFrame") + wrapper = QtWidgets.QWidget(self) wrapper.setObjectName("PopupWrapper") @@ -184,9 +187,12 @@ class FiltersPopup(QtWidgets.QWidget): wraper_layout.setSpacing(5) main_layout = QtWidgets.QHBoxLayout(self) - main_layout.setContentsMargins(0, 0, 0, 0) + 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 @@ -211,13 +217,26 @@ class FiltersPopup(QtWidgets.QWidget): for item in filter_items: widget = FilterItemButton(item, self._wrapper) widget.filter_requested.connect(self.filter_requested) - self._wrapper_layout.addWidget(widget) + 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) + 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): @@ -422,6 +441,9 @@ class FilterValuePopup(QtWidgets.QWidget): 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") @@ -438,7 +460,7 @@ class FilterValuePopup(QtWidgets.QWidget): wraper_layout.addWidget(items_view, 0) main_layout = QtWidgets.QHBoxLayout(self) - main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setContentsMargins(2, 2, 2, 2) main_layout.addWidget(wrapper) text_input.textChanged.connect(self._text_changed) @@ -446,6 +468,9 @@ class FilterValuePopup(QtWidgets.QWidget): items_view.value_changed.connect(self._selection_changed) + shadow_frame.stackUnder(wrapper) + + self._shadow_frame = shadow_frame self._wrapper = wrapper self._wrapper_layout = wraper_layout self._text_input = text_input @@ -505,6 +530,7 @@ class FilterValuePopup(QtWidgets.QWidget): super().showEvent(event) if self._active_widget is not None: self._active_widget.setFocus() + self._update_shadow() def closeEvent(self, event): super().closeEvent(event) @@ -514,6 +540,15 @@ class FilterValuePopup(QtWidgets.QWidget): 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: From fc9945959043f2aeba7116635afc5693ab5f9b13 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 11:08:34 +0200 Subject: [PATCH 07/59] make filters empty --- client/ayon_core/tools/loader/ui/window.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index f236a1e3ae..7a3d198ab6 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -506,6 +506,10 @@ class LoaderWindow(QtWidgets.QWidget): 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] = [] status_items: list[StatusItem] = [] if project_name: From 7ae9fa9cf5b2dff79bd582b4aeaa4b51c77b741a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 11:14:10 +0200 Subject: [PATCH 08/59] add no items to select from item and stretch --- client/ayon_core/tools/loader/ui/search_bar.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index ac0718f988..c19a3a1e18 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -414,7 +414,14 @@ class FilterValueItemsView(QtWidgets.QWidget): ) widget.selected.connect(self._on_item_clicked) self._widgets_by_id[widget_id] = widget - self._content_layout.addWidget(widget) + self._content_layout.addWidget(widget, 0) + + if self._content_layout.count() == 0: + empty_label = QtWidgets.QLabel( + "No items to select from...", self + ) + self._content_layout.addWidget(empty_label, 0) + self._content_layout.addStretch(1) def _on_item_clicked(self, widget_id): widget = self._widgets_by_id.get(widget_id) From 9961f203c50b440b8d8a1146e9e7fccc72eb0949 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 11:31:00 +0200 Subject: [PATCH 09/59] handle edit requests --- client/ayon_core/tools/loader/ui/search_bar.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index c19a3a1e18..eba7d8f7cb 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -641,6 +641,7 @@ class FiltersBar(ClickableFrame): 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 @@ -662,11 +663,12 @@ class FiltersBar(ClickableFrame): old_popup, self._filters_popup = self._filters_popup, filters_popup - self._show_popup(filters_popup) - + self._filter_value_popup.setVisible(False) old_popup.setVisible(False) old_popup.deleteLater() + self._show_popup(filters_popup) + def _on_filters_request(self): self._show_filters_popup() @@ -690,12 +692,14 @@ class FiltersBar(ClickableFrame): self._filter_value_popup, filter_value_popup ) - self._show_popup(filter_value_popup) - self._on_filter_value_change(filter_def.name) - 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() From bbeaad95a0bd15d09b4b5e79e4a49d9f552e6f88 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 11:40:16 +0200 Subject: [PATCH 10/59] wrap filters widget --- .../ayon_core/tools/loader/ui/search_bar.py | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index eba7d8f7cb..ffd08e71a1 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -593,7 +593,13 @@ class FiltersBar(ClickableFrame): search_btn.setFlat(True) search_btn.setObjectName("SearchButton") - filters_widget = QtWidgets.QWidget(self) + # 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) @@ -603,12 +609,13 @@ class FiltersBar(ClickableFrame): main_layout.setContentsMargins(4, 4, 4, 4) main_layout.setSpacing(5) main_layout.addWidget(search_btn, 0) - main_layout.addWidget(filters_widget, 1) + main_layout.addWidget(filters_wrap, 1) search_btn.clicked.connect(self._on_filters_request) self.clicked.connect(self._on_clicked) self._search_btn = search_btn + self._filters_wrap = filters_wrap self._filters_widget = filters_widget self._filters_layout = filters_layout self._widgets_by_name = {} @@ -616,6 +623,14 @@ class FiltersBar(ClickableFrame): self._filters_popup = FiltersPopup(self) self._filter_value_popup = FilterValuePopup(self) + def showEvent(self, event): + super().showEvent(event) + self._update_filters_geo() + + def resizeEvent(self, event): + super().resizeEvent(event) + self._update_filters_geo() + def set_search_items(self, filter_defs: list[FilterDefinition]): self._filter_defs_by_name = { filter_def.name: filter_def @@ -647,6 +662,13 @@ class FiltersBar(ClickableFrame): 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)) + geo.setWidth(geo.width() * 10) + + self._filters_widget.setGeometry(geo) + def _on_clicked(self): self._show_filters_popup() From d0889e33f65d6f1fb77e3e9b4df9093ff0d962fe Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 11:41:41 +0200 Subject: [PATCH 11/59] set arbitrary width --- client/ayon_core/tools/loader/ui/search_bar.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index ffd08e71a1..a48324bc53 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -665,7 +665,8 @@ class FiltersBar(ClickableFrame): def _update_filters_geo(self): geo = self._filters_wrap.geometry() geo.moveTopLeft(QtCore.QPoint(0, 0)) - geo.setWidth(geo.width() * 10) + # Arbitrary width + geo.setWidth(1000) self._filters_widget.setGeometry(geo) From 75154db1858395d9ccce5cd108c56a1e3148dc5d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 12:03:17 +0200 Subject: [PATCH 12/59] fix events propagation --- client/ayon_core/style/style.css | 3 +++ .../ayon_core/tools/loader/ui/search_bar.py | 13 ++++++------ client/ayon_core/tools/utils/widgets.py | 20 ++++++++++++------- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index 525cf28633..c123751ac4 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -904,6 +904,9 @@ FilterValueItemsView #ContentWidget { SearchItemDisplayWidget { border-radius: 5px; } +SearchItemDisplayWidget:hover { + background: {color:bg-buttons}; +} SearchItemDisplayWidget #ValueWidget { border-radius: 3px; background: {color:bg-buttons}; diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index a48324bc53..c234d7d47b 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -10,7 +10,6 @@ from ayon_core.tools.utils import ( get_qt_icon, SquareButton, BaseClickableFrame, - ClickableFrame, PixmapLabel, ) @@ -75,7 +74,7 @@ class CloseButton(SquareButton): icon.paint(painter, rect) -class SearchItemDisplayWidget(QtWidgets.QFrame): +class SearchItemDisplayWidget(BaseClickableFrame): """Widget displaying a set filter in the bar.""" close_requested = QtCore.Signal(str) edit_requested = QtCore.Signal(str) @@ -92,6 +91,7 @@ class SearchItemDisplayWidget(QtWidgets.QFrame): 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("") @@ -139,7 +139,7 @@ class SearchItemDisplayWidget(QtWidgets.QFrame): def _on_remove_clicked(self): self.close_requested.emit(self._filter_def.name) - def _request_edit(self): + def _mouse_release_callback(self): self.edit_requested.emit(self._filter_def.name) @@ -174,7 +174,7 @@ class FiltersPopup(QtWidgets.QWidget): def __init__(self, parent): super().__init__(parent) self.setWindowFlags(QtCore.Qt.Popup | QtCore.Qt.FramelessWindowHint) - self.setAttribute(QtCore.Qt.WA_TranslucentBackground) + self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) shadow_frame = QtWidgets.QFrame(self) shadow_frame.setObjectName("ShadowFrame") @@ -577,7 +577,7 @@ class FilterValuePopup(QtWidgets.QWidget): self.value_changed.emit(self._filter_name) -class FiltersBar(ClickableFrame): +class FiltersBar(BaseClickableFrame): filters_changed = QtCore.Signal() def __init__(self, parent): @@ -612,7 +612,6 @@ class FiltersBar(ClickableFrame): main_layout.addWidget(filters_wrap, 1) search_btn.clicked.connect(self._on_filters_request) - self.clicked.connect(self._on_clicked) self._search_btn = search_btn self._filters_wrap = filters_wrap @@ -670,7 +669,7 @@ class FiltersBar(ClickableFrame): self._filters_widget.setGeometry(geo) - def _on_clicked(self): + def _mouse_release_callback(self): self._show_filters_popup() def _show_filters_popup(self): diff --git a/client/ayon_core/tools/utils/widgets.py b/client/ayon_core/tools/utils/widgets.py index 0cd6d68ab3..70e5e3a0e3 100644 --- a/client/ayon_core/tools/utils/widgets.py +++ b/client/ayon_core/tools/utils/widgets.py @@ -426,7 +426,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 @@ -434,17 +434,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): From e9981081aabf7974276342a58b4a2f02f612ad23 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 12:03:30 +0200 Subject: [PATCH 13/59] extend the size to 3000 --- client/ayon_core/tools/loader/ui/search_bar.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index c234d7d47b..629212876a 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -665,7 +665,7 @@ class FiltersBar(BaseClickableFrame): geo = self._filters_wrap.geometry() geo.moveTopLeft(QtCore.QPoint(0, 0)) # Arbitrary width - geo.setWidth(1000) + geo.setWidth(3000) self._filters_widget.setGeometry(geo) From de15350fe67c1cb0e9fd5ac4ec3ea5b7204a9d94 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 12:38:13 +0200 Subject: [PATCH 14/59] emit filter changed signal --- client/ayon_core/tools/loader/ui/search_bar.py | 13 ++++++++++++- client/ayon_core/tools/loader/ui/window.py | 15 ++++++++++++--- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index 629212876a..6852500f9f 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -578,7 +578,7 @@ class FilterValuePopup(QtWidgets.QWidget): class FiltersBar(BaseClickableFrame): - filters_changed = QtCore.Signal() + filter_changed = QtCore.Signal(str) def __init__(self, parent): super().__init__(parent) @@ -636,6 +636,16 @@ class FiltersBar(BaseClickableFrame): 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 add_item(self, name: str): """Add a new item to the search bar. @@ -734,6 +744,7 @@ class FiltersBar(BaseClickableFrame): 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) diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index 7a3d198ab6..1e2904b667 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -255,7 +255,7 @@ class LoaderWindow(QtWidgets.QWidget): folders_filter_input.textChanged.connect( self._on_folder_filter_change ) - search_bar.filters_changed.connect(self._on_filters_change) + search_bar.filter_changed.connect(self._on_filter_change) # products_filter_input.textChanged.connect( # self._on_product_filter_change # ) @@ -443,8 +443,17 @@ class LoaderWindow(QtWidgets.QWidget): self._product_group_checkbox.isChecked() ) - def _on_filters_change(self): - pass + 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) def _on_tasks_selection_change(self, event): self._products_widget.set_tasks_filter(event["task_ids"]) From de6c4c434218320bf4530d2f54d394d95371d20a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 12:38:26 +0200 Subject: [PATCH 15/59] fix status names filter --- client/ayon_core/tools/loader/ui/products_delegates.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/loader/ui/products_delegates.py b/client/ayon_core/tools/loader/ui/products_delegates.py index 8cece4687f..7a7ffe1b90 100644 --- a/client/ayon_core/tools/loader/ui/products_delegates.py +++ b/client/ayon_core/tools/loader/ui/products_delegates.py @@ -185,7 +185,9 @@ 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) From 7a03ba98f9cdabbbdea77f9bd3cdf6290a62acf5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 12:39:55 +0200 Subject: [PATCH 16/59] emi signal on fitler removement --- client/ayon_core/tools/loader/ui/search_bar.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index 6852500f9f..cbecccb594 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -763,3 +763,4 @@ class FiltersBar(BaseClickableFrame): self._filters_layout.takeAt(idx) widget.setVisible(False) widget.deleteLater() + self.filter_changed.emit(name) From badfcbfaa5acf0028eeea5b537700ea183d9e33f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 12:41:49 +0200 Subject: [PATCH 17/59] get rid of super --- client/ayon_core/tools/utils/widgets.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/tools/utils/widgets.py b/client/ayon_core/tools/utils/widgets.py index 70e5e3a0e3..c4862304f1 100644 --- a/client/ayon_core/tools/utils/widgets.py +++ b/client/ayon_core/tools/utils/widgets.py @@ -1159,7 +1159,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) @@ -1168,17 +1168,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() From 05f2230f18f1e64a51569c07b0972648b4c75709 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 12:53:36 +0200 Subject: [PATCH 18/59] added border to bar --- client/ayon_core/style/style.css | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index c123751ac4..17f852b6e3 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -865,6 +865,7 @@ HintedLineEditButton { /* Launcher specific stylesheets */ FiltersBar { background: {color:bg-inputs}; + border: 1px solid {color:border}; border-radius: 5px; } From 0f56f06b2c26fcfc1045abe87177fd4f3ae06ed5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 14:35:25 +0200 Subject: [PATCH 19/59] fix None value handling --- client/ayon_core/tools/loader/ui/search_bar.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index cbecccb594..b2c0e73bb5 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -117,7 +117,9 @@ class SearchItemDisplayWidget(BaseClickableFrame): def set_value(self, value: "str | list[str]"): text = "" ellide = True - if isinstance(value, str): + if value is None: + pass + elif isinstance(value, str): text = value elif len(value) == 1: text = value[0] From 2bc62fc2a2de51474382ecbb65855503b4fb1c0b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 14:35:56 +0200 Subject: [PATCH 20/59] added helper buttons --- .../ayon_core/tools/loader/ui/search_bar.py | 61 +++++++++++++++++++ client/ayon_core/tools/loader/ui/window.py | 1 + 2 files changed, 62 insertions(+) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index b2c0e73bb5..bf04fec926 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -315,10 +315,30 @@ class FilterValueItemsView(QtWidgets.QWidget): scroll_area.setWidget(content_widget) + btns_widget = QtWidgets.QWidget(self) + btns_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + + select_all_btn = QtWidgets.QPushButton("Select all", btns_widget) + clear_btn = QtWidgets.QPushButton("Clear", btns_widget) + swap_btn = QtWidgets.QPushButton("Swap", btns_widget) + + btns_layout = QtWidgets.QHBoxLayout(btns_widget) + btns_layout.setContentsMargins(0, 0, 0, 0) + btns_layout.addStretch(1) + btns_layout.addWidget(select_all_btn, 0) + btns_layout.addWidget(clear_btn, 0) + btns_layout.addWidget(swap_btn, 0) + main_layout = QtWidgets.QVBoxLayout(self) main_layout.setContentsMargins(0, 0, 0, 0) main_layout.addWidget(scroll_area) + main_layout.addWidget(btns_widget, 0) + 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._btns_widget = btns_widget self._multiselection = False self._content_layout = content_layout self._last_selected_widget = None @@ -368,6 +388,11 @@ class FilterValueItemsView(QtWidgets.QWidget): 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 @@ -422,9 +447,45 @@ class FilterValueItemsView(QtWidgets.QWidget): empty_label = QtWidgets.QLabel( "No items to select from...", self ) + self._btns_widget.setVisible(False) self._content_layout.addWidget(empty_label, 0) + else: + self._btns_widget.setVisible(self._multiselection) self._content_layout.addStretch(1) + 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: diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index 1e2904b667..6f87e95375 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -451,6 +451,7 @@ class LoaderWindow(QtWidgets.QWidget): 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) From ff2645e335af1e9b1b91fe45d7c22c178c7ee64b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 15:27:21 +0200 Subject: [PATCH 21/59] remove commented code --- client/ayon_core/tools/loader/ui/window.py | 58 +++------------------- 1 file changed, 6 insertions(+), 52 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index 6f87e95375..a8361caeab 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -18,11 +18,9 @@ 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 @@ -186,15 +184,6 @@ class LoaderWindow(QtWidgets.QWidget): products_inputs_widget = QtWidgets.QWidget(products_wrap_widget) search_bar = FiltersBar(products_inputs_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) product_group_checkbox = QtWidgets.QCheckBox( "Enable grouping", products_inputs_widget) @@ -202,9 +191,6 @@ 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(search_bar, 1) products_inputs_layout.addWidget(product_group_checkbox, 0) @@ -256,15 +242,6 @@ class LoaderWindow(QtWidgets.QWidget): self._on_folder_filter_change ) search_bar.filter_changed.connect(self._on_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 - # ) product_group_checkbox.stateChanged.connect( self._on_product_group_change ) @@ -323,9 +300,6 @@ class LoaderWindow(QtWidgets.QWidget): self._tasks_widget = tasks_widget self._search_bar = search_bar - # 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 @@ -364,12 +338,6 @@ 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): @@ -459,17 +427,6 @@ class LoaderWindow(QtWidgets.QWidget): def _on_tasks_selection_change(self, event): self._products_widget.set_tasks_filter(event["task_ids"]) - # def _on_product_filter_change(self, text): - # self._products_widget.set_name_filter(text) - # - # 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) @@ -520,15 +477,12 @@ class LoaderWindow(QtWidgets.QWidget): self._search_bar.set_search_items([]) return - product_type_items: list[ProductTypeItem] = [] - status_items: list[StatusItem] = [] - if project_name: - product_type_items = self._controller.get_product_type_items( - project_name - ) - status_items = self._controller.get_project_status_items( - project_name - ) + 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) + ) filter_product_type_items = [ { From 93b5ea5c31fe1bb56ee1049dbc885f0b5546a7d1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 15:28:01 +0200 Subject: [PATCH 22/59] implemented helper functions to get tags from project --- .../ayon_core/tools/common_models/__init__.py | 2 ++ .../tools/common_models/hierarchy.py | 28 ++++++++++++++++ .../ayon_core/tools/common_models/projects.py | 27 ++++++++++++++++ client/ayon_core/tools/loader/abstract.py | 32 ++++++++++++++++++- client/ayon_core/tools/loader/control.py | 11 +++++++ 5 files changed, 99 insertions(+), 1 deletion(-) 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..6b861d8fa5 100644 --- a/client/ayon_core/tools/common_models/hierarchy.py +++ b/client/ayon_core/tools/common_models/hierarchy.py @@ -217,6 +217,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 +237,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 +517,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) diff --git a/client/ayon_core/tools/common_models/projects.py b/client/ayon_core/tools/common_models/projects.py index 7ec941e6bd..8f3135b2d5 100644 --- a/client/ayon_core/tools/common_models/projects.py +++ b/client/ayon_core/tools/common_models/projects.py @@ -1,6 +1,9 @@ +from __future__ import annotations + import contextlib from abc import ABC, abstractmethod from typing import Dict, Any +from dataclasses import dataclass import ayon_api @@ -72,6 +75,14 @@ class StatusItem: ) +@dataclass +class TagItem: + """Tag definition set on project anatomy.""" + name: str + color: str + + + class FolderTypeItem: """Item representing folder type of project. @@ -288,6 +299,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/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index d0d7cd430b..8ae82c7e02 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -1,3 +1,4 @@ +from __future__ import annotations from abc import ABC, abstractmethod from typing import List @@ -6,6 +7,7 @@ from ayon_core.lib.attribute_definitions import ( serialize_attr_defs, deserialize_attr_defs, ) +from ayon_core.tools.common_models import TagItem class ProductTypeItem: @@ -517,8 +519,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 @@ -590,6 +605,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) From fb6386cb8f13078247350800f423b6d73471a207 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 15:53:51 +0200 Subject: [PATCH 23/59] added tags to version item --- client/ayon_core/tools/loader/abstract.py | 4 ++++ client/ayon_core/tools/loader/models/products.py | 2 ++ 2 files changed, 6 insertions(+) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index 8ae82c7e02..09d900074c 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -115,6 +115,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. @@ -133,6 +134,7 @@ class VersionItem: task_id, thumbnail_id, published_time, + tags, author, status, frame_range, @@ -150,6 +152,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 @@ -210,6 +213,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, diff --git a/client/ayon_core/tools/loader/models/products.py b/client/ayon_core/tools/loader/models/products.py index 34acc0550c..a8dd269bc3 100644 --- a/client/ayon_core/tools/loader/models/products.py +++ b/client/ayon_core/tools/loader/models/products.py @@ -19,6 +19,7 @@ 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 +60,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, From 715031a9b55e6ceb97b74ebc86435d8d8dd37ca9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 15:55:02 +0200 Subject: [PATCH 24/59] added version tags filter --- .../tools/loader/ui/products_delegates.py | 60 +++++++++++++++++-- .../tools/loader/ui/products_model.py | 6 ++ .../tools/loader/ui/products_widget.py | 23 ++++++- client/ayon_core/tools/loader/ui/window.py | 27 +++++++++ 4 files changed, 109 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/products_delegates.py b/client/ayon_core/tools/loader/ui/products_delegates.py index 7a7ffe1b90..2e98d14253 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,14 +20,22 @@ from .products_model import ( SYNC_REMOTE_SITE_AVAILABILITY, ) -STATUS_NAME_ROLE = QtCore.Qt.UserRole + 1 +VERSION_ID_ROLE = QtCore.Qt.UserRole + 1 TASK_ID_ROLE = QtCore.Qt.UserRole + 2 +STATUS_NAME_ROLE = QtCore.Qt.UserRole + 3 class VersionsModel(QtGui.QStandardItemModel): def __init__(self): super().__init__() self._items_by_id = {} + self._tags_by_version_id = {} + + def get_version_tags(self, version_id: str) -> set[str]: + tags = self._tags_by_version_id.get(version_id) + if tags is None: + tags = set() + return tags def update_versions(self, version_items): version_ids = { @@ -39,6 +49,7 @@ class VersionsModel(QtGui.QStandardItemModel): item = self._items_by_id.pop(item_id) root_item.removeRow(item.row()) + tags_by_version_id = {} for idx, version_item in enumerate(version_items): version_id = version_item.version_id @@ -48,11 +59,14 @@ 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_id, VERSION_ID_ROLE) item.setData(version_item.status, STATUS_NAME_ROLE) item.setData(version_item.task_id, TASK_ID_ROLE) + tags_by_version_id[version_id] = set(version_item.tags) if item.row() != idx: root_item.insertRow(idx, item) + self._tags_by_version_id = tags_by_version_id class VersionsFilterModel(QtCore.QSortFilterProxyModel): @@ -60,22 +74,39 @@ class VersionsFilterModel(QtCore.QSortFilterProxyModel): super().__init__() self._status_filter = None self._task_ids_filter = None + self._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) + if index is None: + index = self.sourceModel().index(row, 0, parent) status = index.data(STATUS_NAME_ROLE) if status not in self._status_filter: return False if self._task_ids_filter: - index = self.sourceModel().index(row, 0, parent) + if index is None: + 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 + + if self._tags_filter is not None: + if not self._tags_filter: + return False + + if index is None: + index = self.sourceModel().index(row, 0, parent) + version_id = index.data(VERSION_ID_ROLE) + + model = self.sourceModel() + tags = model.get_version_tags(version_id) + if not tags & self._tags_filter: + return False + return True def set_tasks_filter(self, task_ids): @@ -90,6 +121,12 @@ class VersionsFilterModel(QtCore.QSortFilterProxyModel): self._status_filter = status_names self.invalidateFilter() + def set_tags_filter(self, tags): + if self._tags_filter == tags: + return + self._tags_filter = tags + self.invalidateFilter() + class VersionComboBox(QtWidgets.QComboBox): value_changed = QtCore.Signal(str, str) @@ -130,6 +167,13 @@ class VersionComboBox(QtWidgets.QComboBox): if self.currentIndex() != 0: self.setCurrentIndex(0) + def set_tags_filter(self, tags): + self._proxy_model.set_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 @@ -173,6 +217,7 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): self._editor_by_id: Dict[str, VersionComboBox] = {} self._task_ids_filter = None self._statuses_filter = None + self._tags_filter = None def displayText(self, value, locale): if not isinstance(value, numbers.Integral): @@ -191,6 +236,13 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): for widget in self._editor_by_id.values(): widget.set_statuses_filter(status_names) + def set_tags_filter(self, tags): + if tags is not None: + tags = set(tags) + self._tags_filter = tags + for widget in self._editor_by_id.values(): + widget.set_tags_filter(tags) + def paint(self, painter, option, index): fg_color = index.data(QtCore.Qt.ForegroundRole) if fg_color: diff --git a/client/ayon_core/tools/loader/ui/products_model.py b/client/ayon_core/tools/loader/ui/products_model.py index cebae9bca7..06af731f8f 100644 --- a/client/ayon_core/tools/loader/ui/products_model.py +++ b/client/ayon_core/tools/loader/ui/products_model.py @@ -41,6 +41,7 @@ SYNC_ACTIVE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 30 SYNC_REMOTE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 31 STATUS_NAME_FILTER_ROLE = QtCore.Qt.UserRole + 32 +VERSION_TAGS_FILTER_ROLE = QtCore.Qt.UserRole + 33 class ProductsModel(QtGui.QStandardItemModel): @@ -422,6 +423,10 @@ class ProductsModel(QtGui.QStandardItemModel): version_item.status for version_item in product_item.version_items.values() } + tags = set() + for version_item in product_item.version_items.values(): + tags |= set(version_item.tags) + if model_item is None: product_id = product_item.product_id model_item = QtGui.QStandardItem(product_item.product_name) @@ -440,6 +445,7 @@ 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(tags), VERSION_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) diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index 94d95b9026..6c18cdc1f9 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -26,6 +26,7 @@ from .products_model import ( VERSION_STATUS_ICON_ROLE, VERSION_THUMBNAIL_ID_ROLE, STATUS_NAME_FILTER_ROLE, + VERSION_TAGS_FILTER_ROLE, ) from .products_delegates import ( VersionDelegate, @@ -41,6 +42,7 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): self._product_type_filters = None self._statuses_filter = None + self._tags_filter = None self._task_ids_filter = None self._ascending_sort = True @@ -67,6 +69,12 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): self._statuses_filter = statuses_filter self.invalidateFilter() + def set_version_tags_filter(self, tags): + if self._tags_filter == tags: + return + self._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 +91,11 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): ): return False + if not self._accept_row_by_role_value( + index, self._tags_filter, VERSION_TAGS_FILTER_ROLE + ): + return False + return super().filterAcceptsRow(source_row, source_parent) def _accept_task_ids_filter(self, index): @@ -102,9 +115,9 @@ 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: + value_s = index.data(role) + for value in value_s.split("|"): + if value in filter_value: return True return False @@ -290,6 +303,10 @@ 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, tags): + self._version_delegate.set_tags_filter(tags) + self._products_proxy_model.set_version_tags_filter(tags) + def set_product_type_filter(self, product_type_filters): """ diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index a8361caeab..a5f74c2c6f 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -424,6 +424,10 @@ class LoaderWindow(QtWidgets.QWidget): 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) + def _on_tasks_selection_change(self, event): self._products_widget.set_tasks_filter(event["task_ids"]) @@ -483,6 +487,14 @@ class LoaderWindow(QtWidgets.QWidget): 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 = [ { @@ -503,6 +515,14 @@ class LoaderWindow(QtWidgets.QWidget): } 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 [] + ] + self._search_bar.set_search_items([ FilterDefinition( @@ -527,6 +547,13 @@ class LoaderWindow(QtWidgets.QWidget): icon=None, items=filter_status_items, ), + FilterDefinition( + name="version_tags", + title="Version tags", + filter_type="list", + icon=None, + items=version_tags, + ), ]) def _on_folders_selection_changed(self, event): From 9b7be088907faccdee3faa3ed632e3e0794902c4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 12 Jun 2025 17:59:28 +0200 Subject: [PATCH 25/59] more descriptiove naming 'version_tags' --- .../tools/loader/ui/products_delegates.py | 36 +++++++++---------- .../tools/loader/ui/products_model.py | 6 ++-- .../tools/loader/ui/products_widget.py | 14 ++++---- 3 files changed, 28 insertions(+), 28 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/products_delegates.py b/client/ayon_core/tools/loader/ui/products_delegates.py index 2e98d14253..8190fce337 100644 --- a/client/ayon_core/tools/loader/ui/products_delegates.py +++ b/client/ayon_core/tools/loader/ui/products_delegates.py @@ -29,10 +29,10 @@ class VersionsModel(QtGui.QStandardItemModel): def __init__(self): super().__init__() self._items_by_id = {} - self._tags_by_version_id = {} + self._version_tags_by_version_id = {} def get_version_tags(self, version_id: str) -> set[str]: - tags = self._tags_by_version_id.get(version_id) + tags = self._version_tags_by_version_id.get(version_id) if tags is None: tags = set() return tags @@ -49,7 +49,7 @@ class VersionsModel(QtGui.QStandardItemModel): item = self._items_by_id.pop(item_id) root_item.removeRow(item.row()) - tags_by_version_id = {} + version_tags_by_version_id = {} for idx, version_item in enumerate(version_items): version_id = version_item.version_id @@ -62,11 +62,11 @@ class VersionsModel(QtGui.QStandardItemModel): item.setData(version_id, VERSION_ID_ROLE) item.setData(version_item.status, STATUS_NAME_ROLE) item.setData(version_item.task_id, TASK_ID_ROLE) - tags_by_version_id[version_id] = set(version_item.tags) + version_tags_by_version_id[version_id] = set(version_item.tags) if item.row() != idx: root_item.insertRow(idx, item) - self._tags_by_version_id = tags_by_version_id + self._version_tags_by_version_id = version_tags_by_version_id class VersionsFilterModel(QtCore.QSortFilterProxyModel): @@ -74,7 +74,7 @@ class VersionsFilterModel(QtCore.QSortFilterProxyModel): super().__init__() self._status_filter = None self._task_ids_filter = None - self._tags_filter = None + self._version_tags_filter = None def filterAcceptsRow(self, row, parent): index = None @@ -94,8 +94,8 @@ class VersionsFilterModel(QtCore.QSortFilterProxyModel): if task_id not in self._task_ids_filter: return False - if self._tags_filter is not None: - if not self._tags_filter: + if self._version_tags_filter is not None: + if not self._version_tags_filter: return False if index is None: @@ -104,7 +104,7 @@ class VersionsFilterModel(QtCore.QSortFilterProxyModel): model = self.sourceModel() tags = model.get_version_tags(version_id) - if not tags & self._tags_filter: + if not tags & self._version_tags_filter: return False return True @@ -121,10 +121,10 @@ class VersionsFilterModel(QtCore.QSortFilterProxyModel): self._status_filter = status_names self.invalidateFilter() - def set_tags_filter(self, tags): - if self._tags_filter == tags: + def set_version_tags_filter(self, tags): + if self._version_tags_filter == tags: return - self._tags_filter = tags + self._version_tags_filter = tags self.invalidateFilter() @@ -167,8 +167,8 @@ class VersionComboBox(QtWidgets.QComboBox): if self.currentIndex() != 0: self.setCurrentIndex(0) - def set_tags_filter(self, tags): - self._proxy_model.set_tags_filter(tags) + def set_version_tags_filter(self, tags): + self._proxy_model.set_version_tags_filter(tags) if self.count() == 0: return if self.currentIndex() != 0: @@ -217,7 +217,7 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): self._editor_by_id: Dict[str, VersionComboBox] = {} self._task_ids_filter = None self._statuses_filter = None - self._tags_filter = None + self._version_tags_filter = None def displayText(self, value, locale): if not isinstance(value, numbers.Integral): @@ -236,12 +236,12 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): for widget in self._editor_by_id.values(): widget.set_statuses_filter(status_names) - def set_tags_filter(self, tags): + def set_version_tags_filter(self, tags): if tags is not None: tags = set(tags) - self._tags_filter = tags + self._version_tags_filter = tags for widget in self._editor_by_id.values(): - widget.set_tags_filter(tags) + widget.set_version_tags_filter(tags) def paint(self, painter, option, index): fg_color = index.data(QtCore.Qt.ForegroundRole) diff --git a/client/ayon_core/tools/loader/ui/products_model.py b/client/ayon_core/tools/loader/ui/products_model.py index 06af731f8f..d3bf6b2e38 100644 --- a/client/ayon_core/tools/loader/ui/products_model.py +++ b/client/ayon_core/tools/loader/ui/products_model.py @@ -423,9 +423,9 @@ class ProductsModel(QtGui.QStandardItemModel): version_item.status for version_item in product_item.version_items.values() } - tags = set() + version_tags = set() for version_item in product_item.version_items.values(): - tags |= set(version_item.tags) + version_tags |= set(version_item.tags) if model_item is None: product_id = product_item.product_id @@ -445,7 +445,7 @@ 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(tags), VERSION_TAGS_FILTER_ROLE) + model_item.setData("|".join(version_tags), VERSION_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) diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index 6c18cdc1f9..0126102d71 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -42,7 +42,7 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): self._product_type_filters = None self._statuses_filter = None - self._tags_filter = None + self._version_tags_filter = None self._task_ids_filter = None self._ascending_sort = True @@ -70,9 +70,9 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): self.invalidateFilter() def set_version_tags_filter(self, tags): - if self._tags_filter == tags: + if self._version_tags_filter == tags: return - self._tags_filter = tags + self._version_tags_filter = tags self.invalidateFilter() def filterAcceptsRow(self, source_row, source_parent): @@ -92,7 +92,7 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): return False if not self._accept_row_by_role_value( - index, self._tags_filter, VERSION_TAGS_FILTER_ROLE + index, self._version_tags_filter, VERSION_TAGS_FILTER_ROLE ): return False @@ -303,9 +303,9 @@ 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, tags): - self._version_delegate.set_tags_filter(tags) - self._products_proxy_model.set_version_tags_filter(tags) + 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_product_type_filter(self, product_type_filters): """ From a404db80451bbbd3c7ba0328f96aecaf07edc4c9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 12 Jun 2025 18:00:25 +0200 Subject: [PATCH 26/59] update flters after refresh --- client/ayon_core/tools/loader/ui/window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index a5f74c2c6f..ddc7ef7329 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -325,7 +325,6 @@ class LoaderWindow(QtWidgets.QWidget): def refresh(self): self._reset_on_show = False self._controller.reset() - self._update_filters() def showEvent(self, event): super().showEvent(event) @@ -462,6 +461,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"] From 9374e19ec3fa13b6fdc3df8b4c0ccb49c31a02ef Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 12 Jun 2025 18:00:48 +0200 Subject: [PATCH 27/59] task items now have tags --- client/ayon_core/tools/common_models/hierarchy.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/common_models/hierarchy.py b/client/ayon_core/tools/common_models/hierarchy.py index 6b861d8fa5..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 @@ -645,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) From c2f9e55dd1f9f44f06aaeb427721a3ed3eecf255 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 12 Jun 2025 18:02:10 +0200 Subject: [PATCH 28/59] added task tags filter base --- .../tools/loader/ui/products_widget.py | 3 +++ client/ayon_core/tools/loader/ui/window.py | 19 ++++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index 0126102d71..1e391895f8 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -307,6 +307,9 @@ class ProductsWidget(QtWidgets.QWidget): 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): + pass + def set_product_type_filter(self, product_type_filters): """ diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index ddc7ef7329..c39f92234e 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -427,6 +427,10 @@ class LoaderWindow(QtWidgets.QWidget): 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"]) @@ -522,7 +526,13 @@ class LoaderWindow(QtWidgets.QWidget): } 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( @@ -554,6 +564,13 @@ class LoaderWindow(QtWidgets.QWidget): icon=None, items=version_tags, ), + FilterDefinition( + name="task_tags", + title="Task tags", + filter_type="list", + icon=None, + items=task_tags, + ), ]) def _on_folders_selection_changed(self, event): From 84bc798458b5b7fddc39196a0790bfec1fa8e54b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 12 Jun 2025 18:02:49 +0200 Subject: [PATCH 29/59] remove task refresh logic --- client/ayon_core/tools/loader/ui/tasks_widget.py | 8 -------- 1 file changed, 8 deletions(-) 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"] From da6bc0b72838864665b3524bfa61d2ac2912a996 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 12 Jun 2025 18:05:01 +0200 Subject: [PATCH 30/59] added task tags data to product --- .../tools/loader/ui/products_model.py | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/loader/ui/products_model.py b/client/ayon_core/tools/loader/ui/products_model.py index d3bf6b2e38..2e257073cf 100644 --- a/client/ayon_core/tools/loader/ui/products_model.py +++ b/client/ayon_core/tools/loader/ui/products_model.py @@ -41,7 +41,8 @@ SYNC_ACTIVE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 30 SYNC_REMOTE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 31 STATUS_NAME_FILTER_ROLE = QtCore.Qt.UserRole + 32 -VERSION_TAGS_FILTER_ROLE = QtCore.Qt.UserRole + 33 +TASK_TAGS_FILTER_ROLE = QtCore.Qt.UserRole + 33 +VERSION_TAGS_FILTER_ROLE = QtCore.Qt.UserRole + 34 class ProductsModel(QtGui.QStandardItemModel): @@ -131,6 +132,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 [ @@ -424,8 +426,14 @@ class ProductsModel(QtGui.QStandardItemModel): 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 @@ -446,6 +454,7 @@ class ProductsModel(QtGui.QStandardItemModel): 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) @@ -476,6 +485,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 ) @@ -490,6 +507,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 From b2c583d2589fa95db84d1e1a243b0fc5aa123a5a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 12 Jun 2025 18:05:13 +0200 Subject: [PATCH 31/59] fixed variable names --- client/ayon_core/tools/loader/ui/products_model.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/products_model.py b/client/ayon_core/tools/loader/ui/products_model.py index 2e257073cf..59199a48e8 100644 --- a/client/ayon_core/tools/loader/ui/products_model.py +++ b/client/ayon_core/tools/loader/ui/products_model.py @@ -227,9 +227,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 From a471b48b04e84b6b0b2887c38770764ac219e8c3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 12 Jun 2025 18:05:58 +0200 Subject: [PATCH 32/59] implemented tags filter for product items --- .../ayon_core/tools/loader/ui/products_widget.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index 1e391895f8..775656f13c 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -27,6 +27,7 @@ from .products_model import ( VERSION_THUMBNAIL_ID_ROLE, STATUS_NAME_FILTER_ROLE, VERSION_TAGS_FILTER_ROLE, + TASK_TAGS_FILTER_ROLE, ) from .products_delegates import ( VersionDelegate, @@ -43,6 +44,7 @@ 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 @@ -75,6 +77,12 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): 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) @@ -96,6 +104,11 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): ): 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): @@ -308,7 +321,7 @@ class ProductsWidget(QtWidgets.QWidget): self._products_proxy_model.set_version_tags_filter(version_tags) def set_task_tags_filter(self, task_tags): - pass + self._products_proxy_model.set_task_tags_filter(task_tags) def set_product_type_filter(self, product_type_filters): """ From e02118b93a43a90821a4e05f8b5c39b48b62fe33 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 12 Jun 2025 18:07:34 +0200 Subject: [PATCH 33/59] cleanup naming conflicts --- .../tools/loader/ui/products_delegates.py | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/products_delegates.py b/client/ayon_core/tools/loader/ui/products_delegates.py index 8190fce337..4283b66e61 100644 --- a/client/ayon_core/tools/loader/ui/products_delegates.py +++ b/client/ayon_core/tools/loader/ui/products_delegates.py @@ -20,12 +20,12 @@ from .products_model import ( SYNC_REMOTE_SITE_AVAILABILITY, ) -VERSION_ID_ROLE = QtCore.Qt.UserRole + 1 -TASK_ID_ROLE = QtCore.Qt.UserRole + 2 -STATUS_NAME_ROLE = QtCore.Qt.UserRole + 3 +COMBO_VERSION_ID_ROLE = QtCore.Qt.UserRole + 1 +COMBO_TASK_ID_ROLE = QtCore.Qt.UserRole + 2 +COMBO_STATUS_NAME_ROLE = QtCore.Qt.UserRole + 3 -class VersionsModel(QtGui.QStandardItemModel): +class ComboVersionsModel(QtGui.QStandardItemModel): def __init__(self): super().__init__() self._items_by_id = {} @@ -59,9 +59,9 @@ 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_id, VERSION_ID_ROLE) - item.setData(version_item.status, STATUS_NAME_ROLE) - item.setData(version_item.task_id, TASK_ID_ROLE) + 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) version_tags_by_version_id[version_id] = set(version_item.tags) if item.row() != idx: @@ -69,7 +69,7 @@ class VersionsModel(QtGui.QStandardItemModel): self._version_tags_by_version_id = version_tags_by_version_id -class VersionsFilterModel(QtCore.QSortFilterProxyModel): +class ComboVersionsFilterModel(QtCore.QSortFilterProxyModel): def __init__(self): super().__init__() self._status_filter = None @@ -83,14 +83,14 @@ class VersionsFilterModel(QtCore.QSortFilterProxyModel): return False if index is None: index = self.sourceModel().index(row, 0, parent) - status = index.data(STATUS_NAME_ROLE) + status = index.data(COMBO_STATUS_NAME_ROLE) if status not in self._status_filter: return False if self._task_ids_filter: if index is None: index = self.sourceModel().index(row, 0, parent) - task_id = index.data(TASK_ID_ROLE) + task_id = index.data(COMBO_TASK_ID_ROLE) if task_id not in self._task_ids_filter: return False @@ -100,7 +100,7 @@ class VersionsFilterModel(QtCore.QSortFilterProxyModel): if index is None: index = self.sourceModel().index(row, 0, parent) - version_id = index.data(VERSION_ID_ROLE) + version_id = index.data(COMBO_VERSION_ID_ROLE) model = self.sourceModel() tags = model.get_version_tags(version_id) @@ -134,8 +134,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) From 9a72f2bafc5da6e3206b18c127036efb084310f5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 12 Jun 2025 18:16:22 +0200 Subject: [PATCH 34/59] better way how to get versions --- client/ayon_core/tools/loader/ui/products_delegates.py | 6 +++++- client/ayon_core/tools/loader/ui/products_model.py | 8 ++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/loader/ui/products_delegates.py b/client/ayon_core/tools/loader/ui/products_delegates.py index 4283b66e61..c22a99ab55 100644 --- a/client/ayon_core/tools/loader/ui/products_delegates.py +++ b/client/ayon_core/tools/loader/ui/products_delegates.py @@ -317,8 +317,12 @@ 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) editor.update_versions(versions, version_id) editor.set_tasks_filter(self._task_ids_filter) diff --git a/client/ayon_core/tools/loader/ui/products_model.py b/client/ayon_core/tools/loader/ui/products_model.py index 59199a48e8..2015c86f92 100644 --- a/client/ayon_core/tools/loader/ui/products_model.py +++ b/client/ayon_core/tools/loader/ui/products_model.py @@ -173,6 +173,14 @@ class ProductsModel(QtGui.QStandardItemModel): self._last_folder_ids ) + 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): From 5a4a888c222aff03ad568f664a1c23a4d8969840 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 12 Jun 2025 18:24:03 +0200 Subject: [PATCH 35/59] faster filtering --- client/ayon_core/tools/loader/ui/products_model.py | 3 +++ client/ayon_core/tools/loader/ui/products_widget.py | 7 ++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/products_model.py b/client/ayon_core/tools/loader/ui/products_model.py index 2015c86f92..b3042629ff 100644 --- a/client/ayon_core/tools/loader/ui/products_model.py +++ b/client/ayon_core/tools/loader/ui/products_model.py @@ -173,6 +173,9 @@ 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: diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index 775656f13c..935b9a147e 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -129,9 +129,10 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): return False value_s = index.data(role) - for value in value_s.split("|"): - if value in filter_value: - return True + if value_s: + for value in value_s.split("|"): + if value in filter_value: + return True return False def lessThan(self, left, right): From 810b9cc573a0ad07604734153f12083a244bddb2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 12 Jun 2025 18:39:07 +0200 Subject: [PATCH 36/59] implemented version filtering by task tags --- .../tools/loader/ui/products_delegates.py | 86 +++++++++++++++---- .../tools/loader/ui/products_widget.py | 1 + 2 files changed, 69 insertions(+), 18 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/products_delegates.py b/client/ayon_core/tools/loader/ui/products_delegates.py index c22a99ab55..e78b32ceb1 100644 --- a/client/ayon_core/tools/loader/ui/products_delegates.py +++ b/client/ayon_core/tools/loader/ui/products_delegates.py @@ -23,21 +23,16 @@ from .products_model import ( 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 ComboVersionsModel(QtGui.QStandardItemModel): def __init__(self): super().__init__() self._items_by_id = {} - self._version_tags_by_version_id = {} - def get_version_tags(self, version_id: str) -> set[str]: - tags = self._version_tags_by_version_id.get(version_id) - if tags is None: - tags = set() - return tags - - 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 @@ -59,14 +54,17 @@ class ComboVersionsModel(QtGui.QStandardItemModel): item = QtGui.QStandardItem(label) item.setData(version_id, QtCore.Qt.UserRole) self._items_by_id[version_id] = item + 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) - self._version_tags_by_version_id = version_tags_by_version_id class ComboVersionsFilterModel(QtCore.QSortFilterProxyModel): @@ -75,6 +73,7 @@ class ComboVersionsFilterModel(QtCore.QSortFilterProxyModel): 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 @@ -99,12 +98,28 @@ class ComboVersionsFilterModel(QtCore.QSortFilterProxyModel): return False if index is None: - index = self.sourceModel().index(row, 0, parent) - version_id = index.data(COMBO_VERSION_ID_ROLE) + 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("|")) - model = self.sourceModel() - tags = model.get_version_tags(version_id) - if not tags & self._version_tags_filter: + 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 @@ -115,6 +130,12 @@ class ComboVersionsFilterModel(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 @@ -160,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: @@ -179,7 +207,12 @@ class VersionComboBox(QtWidgets.QComboBox): 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 = [ @@ -190,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: @@ -218,6 +253,7 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): 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): @@ -243,6 +279,13 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): 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: @@ -254,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() @@ -323,9 +366,16 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): 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_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index 935b9a147e..8cb1d48acb 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -322,6 +322,7 @@ class ProductsWidget(QtWidgets.QWidget): 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): From aa252af0a4aca29b16fd8364d0f7f1771b697da7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 13 Jun 2025 09:38:09 +0200 Subject: [PATCH 37/59] add typehints --- client/ayon_core/tools/loader/abstract.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index 09d900074c..8d5e631852 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -1,13 +1,13 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import List +from typing import Iterable, Optional from ayon_core.lib.attribute_definitions import ( AbstractAttrDef, serialize_attr_defs, deserialize_attr_defs, ) -from ayon_core.tools.common_models import TagItem +from ayon_core.tools.common_models import TaskItem, TagItem class ProductTypeItem: @@ -360,8 +360,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 @@ -561,7 +561,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: From 61af049a8c3db7410e76e0e934cd318893a6cc61 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 13 Jun 2025 10:44:38 +0200 Subject: [PATCH 38/59] ruff fixes --- client/ayon_core/tools/loader/ui/search_bar.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index bf04fec926..840013c97c 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -5,7 +5,7 @@ from typing import Any, Optional from qtpy import QtCore, QtWidgets, QtGui -from ayon_core.style import load_stylesheet, get_objected_colors +from ayon_core.style import get_objected_colors from ayon_core.tools.utils import ( get_qt_icon, SquareButton, @@ -682,7 +682,7 @@ class FiltersBar(BaseClickableFrame): self._filters_layout = filters_layout self._widgets_by_name = {} self._filter_defs_by_name = {} - self._filters_popup = FiltersPopup(self) + self._filters_popup = FiltersPopup(self) self._filter_value_popup = FilterValuePopup(self) def showEvent(self, event): From 60caf47cfffd6ad5507829073f29c085c0fa6324 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 13 Jun 2025 10:48:07 +0200 Subject: [PATCH 39/59] remove unncessary line --- client/ayon_core/tools/common_models/projects.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/tools/common_models/projects.py b/client/ayon_core/tools/common_models/projects.py index 8f3135b2d5..69ac4e34a8 100644 --- a/client/ayon_core/tools/common_models/projects.py +++ b/client/ayon_core/tools/common_models/projects.py @@ -82,7 +82,6 @@ class TagItem: color: str - class FolderTypeItem: """Item representing folder type of project. From 4f0c2a51c87ed1f3234c63bf894eafbcaf11a785 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 13 Jun 2025 14:40:50 +0200 Subject: [PATCH 40/59] fix missing group checkbox --- client/ayon_core/tools/loader/ui/window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index c39f92234e..40802d3d88 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -198,7 +198,7 @@ class LoaderWindow(QtWidgets.QWidget): products_wrap_layout = QtWidgets.QVBoxLayout(products_wrap_widget) products_wrap_layout.setContentsMargins(0, 0, 0, 0) - products_wrap_layout.addWidget(search_bar, 0) + products_wrap_layout.addWidget(products_inputs_widget, 0) products_wrap_layout.addWidget(products_widget, 1) right_panel_splitter = QtWidgets.QSplitter(main_splitter) From 9033640efa2cf9e7225c5d7ee226a7cd8f2aa218 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 13 Jun 2025 14:48:50 +0200 Subject: [PATCH 41/59] remove unused widgets --- .../tools/loader/ui/product_types_combo.py | 170 ------------------ .../tools/loader/ui/statuses_combo.py | 157 ---------------- 2 files changed, 327 deletions(-) delete mode 100644 client/ayon_core/tools/loader/ui/product_types_combo.py delete mode 100644 client/ayon_core/tools/loader/ui/statuses_combo.py 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/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() From c34e3bb15ed6d5c4cb50b993680921856cf567db Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 13 Jun 2025 15:22:22 +0200 Subject: [PATCH 42/59] auto set product type filters --- .../ayon_core/tools/loader/ui/search_bar.py | 13 ++++++++++ client/ayon_core/tools/loader/ui/window.py | 25 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index 840013c97c..5e9e409ee1 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -709,6 +709,19 @@ class FiltersBar(BaseClickableFrame): 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. diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index 40802d3d88..a3a476b330 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -318,6 +318,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() ) @@ -573,6 +575,29 @@ class LoaderWindow(QtWidgets.QWidget): ), ]) + # 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"]) self._update_thumbnails() From 93c2e1ad900701147b96c38886791fdbb7be55e5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 13 Jun 2025 15:48:40 +0200 Subject: [PATCH 43/59] added quick filter input for items --- .../ayon_core/tools/loader/ui/search_bar.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index 5e9e409ee1..df77f5405e 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -300,6 +300,12 @@ class FilterValueItemsView(QtWidgets.QWidget): def __init__(self, parent): super().__init__(parent) + filter_input = QtWidgets.QLineEdit(self) + + 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() @@ -310,6 +316,7 @@ class FilterValueItemsView(QtWidgets.QWidget): content_widget = QtWidgets.QWidget(scroll_area) content_widget.setObjectName("ContentWidget") + content_layout = QtWidgets.QVBoxLayout(content_widget) content_layout.setContentsMargins(0, 0, 0, 0) @@ -331,19 +338,31 @@ class FilterValueItemsView(QtWidgets.QWidget): 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_widget, 0) + filter_timeout.timeout.connect(self._on_filter_timeout) + filter_input.textChanged.connect(self._on_filter_change) 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 _on_filter_timeout(self): + self._filter_input.setFocus() + def set_value(self, value): current_value = self.get_value() if self._multiselection: @@ -453,6 +472,12 @@ class FilterValueItemsView(QtWidgets.QWidget): self._btns_widget.setVisible(self._multiselection) self._content_layout.addStretch(1) + 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(): From 5ebdd74bf728621a2d0fb605223c40855408b623 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 13 Jun 2025 15:49:23 +0200 Subject: [PATCH 44/59] reset filter text on fitlers change --- client/ayon_core/tools/loader/ui/search_bar.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index df77f5405e..7108ea8b11 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -446,8 +446,11 @@ class FilterValueItemsView(QtWidgets.QWidget): 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 From f1e93e980755387a77a62943e8b5329d6ecf82cf Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 13 Jun 2025 15:59:40 +0200 Subject: [PATCH 45/59] close popups on enter press --- .../ayon_core/tools/loader/ui/search_bar.py | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index 7108ea8b11..31069904b0 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -199,6 +199,13 @@ class FiltersPopup(QtWidgets.QWidget): 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 + super().keyPressEvent(event) + def set_preferred_width(self, width: int): self._preferred_width = width @@ -296,12 +303,15 @@ class FilterValueItemButton(BaseClickableFrame): class FilterValueItemsView(QtWidgets.QWidget): value_changed = QtCore.Signal() + close_requested = QtCore.Signal() def __init__(self, parent): super().__init__(parent) filter_input = QtWidgets.QLineEdit(self) + # 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) @@ -344,6 +354,7 @@ class FilterValueItemsView(QtWidgets.QWidget): filter_timeout.timeout.connect(self._on_filter_timeout) filter_input.textChanged.connect(self._on_filter_change) + filter_input.returnPressed.connect(self.close_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) @@ -360,8 +371,12 @@ class FilterValueItemsView(QtWidgets.QWidget): super().showEvent(event) self._filter_timeout.start() - def _on_filter_timeout(self): - self._filter_input.setFocus() + def keyPressEvent(self, event): + if event.key() in (QtCore.Qt.Key_Enter, QtCore.Qt.Key_Return): + event.accept() + self.close_requested.emit() + return + super().keyPressEvent(event) def set_value(self, value): current_value = self.get_value() @@ -470,11 +485,16 @@ class FilterValueItemsView(QtWidgets.QWidget): "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(): @@ -565,6 +585,7 @@ class FilterValuePopup(QtWidgets.QWidget): text_input.returnPressed.connect(self._text_confirmed) items_view.value_changed.connect(self._selection_changed) + items_view.close_requested.connect(self._close_requested) shadow_frame.stackUnder(wrapper) @@ -667,6 +688,9 @@ class FilterValuePopup(QtWidgets.QWidget): def _selection_changed(self): self.value_changed.emit(self._filter_name) + def _close_requested(self): + self.close() + class FiltersBar(BaseClickableFrame): filter_changed = QtCore.Signal(str) From 4180fcfdb4f6a12636eb812ed31c98bcd166a23e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 13 Jun 2025 16:07:34 +0200 Subject: [PATCH 46/59] added ctrl + F shortcut to show filter popup --- client/ayon_core/tools/loader/ui/search_bar.py | 6 +++--- client/ayon_core/tools/loader/ui/window.py | 9 +++++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index 31069904b0..8ab455a158 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -808,9 +808,9 @@ class FiltersBar(BaseClickableFrame): self._filters_widget.setGeometry(geo) def _mouse_release_callback(self): - self._show_filters_popup() + self.show_filters_popup() - def _show_filters_popup(self): + def show_filters_popup(self): filter_defs = [ filter_def for filter_def in self._filter_defs_by_name.values() @@ -830,7 +830,7 @@ class FiltersBar(BaseClickableFrame): self._show_popup(filters_popup) def _on_filters_request(self): - self._show_filters_popup() + self.show_filters_popup() def _on_filter_request(self, filter_name: str): """Handle filter request from the popup.""" diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index a3a476b330..314a4f749e 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -346,6 +346,15 @@ class LoaderWindow(QtWidgets.QWidget): ctrl_pressed = QtCore.Qt.ControlModifier & modifiers # Grouping products on pressing Ctrl + G + if ( + ctrl_pressed + and event.key() == QtCore.Qt.Key_F + and not event.isAutoRepeat() + ): + self._search_bar.show_filters_popup() + event.setAccepted(True) + return + if ( ctrl_pressed and event.key() == QtCore.Qt.Key_G From 3f0e96cb6253cabdd4c379cd4d4222eb8366cdb5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 13 Jun 2025 16:08:47 +0200 Subject: [PATCH 47/59] better method position --- .../ayon_core/tools/loader/ui/search_bar.py | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index 8ab455a158..9b74f63557 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -745,6 +745,25 @@ class FiltersBar(BaseClickableFrame): super().resizeEvent(event) self._update_filters_geo() + 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.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 @@ -810,25 +829,6 @@ class FiltersBar(BaseClickableFrame): def _mouse_release_callback(self): self.show_filters_popup() - 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.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 _on_filters_request(self): self.show_filters_popup() From d936c2f4d091bbe4a7e9887ec4deb927ad59ad04 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 13 Jun 2025 16:27:07 +0200 Subject: [PATCH 48/59] define key sequences --- client/ayon_core/tools/loader/ui/window.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index 314a4f749e..01d96410ed 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -23,6 +23,13 @@ from .info_widget import InfoWidget from .repres_widget import RepresentationsWidget 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): def __init__(self, messages, parent=None): @@ -342,22 +349,18 @@ class LoaderWindow(QtWidgets.QWidget): self._reset_on_show = True def keyPressEvent(self, event): - modifiers = event.modifiers() - ctrl_pressed = QtCore.Qt.ControlModifier & modifiers - - # Grouping products on pressing Ctrl + G + combination = event.keyCombination() if ( - ctrl_pressed - and event.key() == QtCore.Qt.Key_F + FIND_KEY_SEQUENCE.matches(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.matches(combination) and not event.isAutoRepeat() ): self._show_group_dialog() From 00f948e9ea08ed3706b9033b630c0372c8749c31 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 13 Jun 2025 16:45:56 +0200 Subject: [PATCH 49/59] fix qkeysequence match comparison --- client/ayon_core/tools/loader/ui/window.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index 01d96410ed..047c6fb159 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -351,7 +351,7 @@ class LoaderWindow(QtWidgets.QWidget): def keyPressEvent(self, event): combination = event.keyCombination() if ( - FIND_KEY_SEQUENCE.matches(combination) + FIND_KEY_SEQUENCE == combination and not event.isAutoRepeat() ): self._search_bar.show_filters_popup() @@ -360,7 +360,7 @@ class LoaderWindow(QtWidgets.QWidget): # Grouping products on pressing Ctrl + G if ( - GROUP_KEY_SEQUENCE.matches(combination) + GROUP_KEY_SEQUENCE == combination and not event.isAutoRepeat() ): self._show_group_dialog() From 83398c6a3e92b93181bd17224b9cd9c46531195a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 13 Jun 2025 16:52:29 +0200 Subject: [PATCH 50/59] auto-fill product name filter on typing in filters popup --- .../ayon_core/tools/loader/ui/search_bar.py | 42 ++++++++++++++++++- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index 9b74f63557..d3e702c031 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -172,6 +172,7 @@ class FilterItemButton(BaseClickableFrame): class FiltersPopup(QtWidgets.QWidget): filter_requested = QtCore.Signal(str) + text_filter_requested = QtCore.Signal(str) def __init__(self, parent): super().__init__(parent) @@ -204,6 +205,19 @@ class FiltersPopup(QtWidgets.QWidget): event.accept() self.close() return + + if event.key() not in ( + QtCore.Qt.Key_Escape, + QtCore.Qt.Key_Tab, + QtCore.Qt.Key_Backtab, + QtCore.Qt.Key_Backspace, + 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): @@ -608,6 +622,17 @@ class FilterValuePopup(QtWidgets.QWidget): sh.setWidth(self._preferred_width) return sh + def set_text_filter(self, text: str): + if self._active_widget is None: + return + + if isinstance(self._active_widget, QtWidgets.QLineEdit): + full_text = self._active_widget.text() + text + self._active_widget.setText(full_text) + self._active_widget.setFocus() + self._active_widget.setCursorPosition(len(full_text)) + return + def set_filter_item( self, filter_def: FilterDefinition, @@ -726,7 +751,13 @@ class FiltersBar(BaseClickableFrame): 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 @@ -734,8 +765,8 @@ class FiltersBar(BaseClickableFrame): self._filters_layout = filters_layout self._widgets_by_name = {} self._filter_defs_by_name = {} - self._filters_popup = FiltersPopup(self) - self._filter_value_popup = FilterValuePopup(self) + self._filters_popup = filters_popup + self._filter_value_popup = filter_value_popup def showEvent(self, event): super().showEvent(event) @@ -753,6 +784,9 @@ class FiltersBar(BaseClickableFrame): ] 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()) @@ -832,6 +866,10 @@ class FiltersBar(BaseClickableFrame): def _on_filters_request(self): self.show_filters_popup() + def _on_text_filter_request(self, text: str): + 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) From d07dcb259021ebb22d8a295375a6bbcb0603f8ee Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 16 Jun 2025 15:26:57 +0200 Subject: [PATCH 51/59] fix keycombination --- client/ayon_core/tools/loader/ui/window.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index 047c6fb159..d056b62b13 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -349,7 +349,10 @@ class LoaderWindow(QtWidgets.QWidget): self._reset_on_show = True def keyPressEvent(self, event): - combination = event.keyCombination() + if hasattr(event, "keyCombination"): + combination = event.keyCombination() + else: + combination = QtGui.QKeySequence(event.modifiers() | event.key()) if ( FIND_KEY_SEQUENCE == combination and not event.isAutoRepeat() From 6006fcbb713e4724740775d3e59f1f50ed23973c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 16 Jun 2025 15:29:18 +0200 Subject: [PATCH 52/59] go to name filtering on backspace --- client/ayon_core/tools/loader/ui/search_bar.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index d3e702c031..4620eb815f 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -206,11 +206,17 @@ class FiltersPopup(QtWidgets.QWidget): self.close() return + if event.key() in ( + QtCore.Qt.Key_Backtab, + QtCore.Qt.Key_Backspace, + ): + self.text_filter_requested.emit("") + event.accept() + return + if event.key() not in ( QtCore.Qt.Key_Escape, QtCore.Qt.Key_Tab, - QtCore.Qt.Key_Backtab, - QtCore.Qt.Key_Backspace, QtCore.Qt.Key_Return, ): text = event.text() From 7c6c054cd7638f40cf6bf5d68d41cc6f4fc32e5f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 16 Jun 2025 15:43:02 +0200 Subject: [PATCH 53/59] handle backspace --- .../ayon_core/tools/loader/ui/search_bar.py | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index 4620eb815f..cb9dada37c 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -14,6 +14,23 @@ from ayon_core.tools.utils import ( ) +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. @@ -633,11 +650,13 @@ class FilterValuePopup(QtWidgets.QWidget): return if isinstance(self._active_widget, QtWidgets.QLineEdit): - full_text = self._active_widget.text() + text - self._active_widget.setText(full_text) - self._active_widget.setFocus() - self._active_widget.setCursorPosition(len(full_text)) - return + kwargs = {} + if text: + kwargs["append_text"] = text + else: + kwargs["backspace"] = True + + set_line_edit_focus(self._active_widget, **kwargs) def set_filter_item( self, From a3a14f08e4e337236c5dcfca30135dcd27ab3034 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 16 Jun 2025 15:43:15 +0200 Subject: [PATCH 54/59] don't force to add filter by name if is not defined --- client/ayon_core/tools/loader/ui/search_bar.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index cb9dada37c..1687b17703 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -892,6 +892,9 @@ class FiltersBar(BaseClickableFrame): 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) From bedd605d2e8186c8ada394743d3a27db3e8fbda5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 16 Jun 2025 15:43:28 +0200 Subject: [PATCH 55/59] change filter of list items on writing --- .../ayon_core/tools/loader/ui/search_bar.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index 1687b17703..1a625cca6d 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -413,6 +413,26 @@ class FilterValueItemsView(QtWidgets.QWidget): 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 + + if 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): From cd7351aa5250b1772e21999a740cf49fab04e71e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 16 Jun 2025 15:47:33 +0200 Subject: [PATCH 56/59] added placeholder --- client/ayon_core/tools/loader/ui/search_bar.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index 1a625cca6d..645d4644d6 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -346,6 +346,7 @@ class FilterValueItemsView(QtWidgets.QWidget): 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 From b9b801f53d128637551002dc22127b62f04b07d1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 18 Jun 2025 09:12:37 +0200 Subject: [PATCH 57/59] support only shift modifier for auto-text filter --- client/ayon_core/tools/loader/ui/search_bar.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index 645d4644d6..d9b4fc65c7 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -231,7 +231,11 @@ class FiltersPopup(QtWidgets.QWidget): event.accept() return - if event.key() not in ( + 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, @@ -423,7 +427,11 @@ class FilterValueItemsView(QtWidgets.QWidget): set_line_edit_focus(self._filter_input, backspace=True) return - if event.key() not in ( + 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, From 48e29d57b846b48b64f39eaf5410e58644e0a0a4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 18 Jun 2025 09:54:04 +0200 Subject: [PATCH 58/59] added back and confirm buttons --- client/ayon_core/style/style.css | 13 ++ .../ayon_core/tools/loader/ui/search_bar.py | 138 +++++++++++++++--- 2 files changed, 132 insertions(+), 19 deletions(-) diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index 400fde3077..82b958f812 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -907,6 +907,19 @@ 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}; diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index d9b4fc65c7..1e74426949 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -11,6 +11,7 @@ from ayon_core.tools.utils import ( SquareButton, BaseClickableFrame, PixmapLabel, + SeparatorWidget, ) @@ -342,9 +343,88 @@ class FilterValueItemButton(BaseClickableFrame): 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) @@ -374,29 +454,46 @@ class FilterValueItemsView(QtWidgets.QWidget): 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("Swap", 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) @@ -619,6 +716,7 @@ class FilterValueItemsView(QtWidgets.QWidget): 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) @@ -631,7 +729,7 @@ class FilterValuePopup(QtWidgets.QWidget): wrapper = QtWidgets.QWidget(self) wrapper.setObjectName("PopupWrapper") - text_input = QtWidgets.QLineEdit(wrapper) + text_input = FilterValueTextInput(wrapper) text_input.setVisible(False) items_view = FilterValueItemsView(wrapper) @@ -647,11 +745,13 @@ class FilterValuePopup(QtWidgets.QWidget): main_layout.setContentsMargins(2, 2, 2, 2) main_layout.addWidget(wrapper) - text_input.textChanged.connect(self._text_changed) - text_input.returnPressed.connect(self._text_confirmed) + 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) @@ -678,14 +778,8 @@ class FilterValuePopup(QtWidgets.QWidget): if self._active_widget is None: return - if isinstance(self._active_widget, QtWidgets.QLineEdit): - kwargs = {} - if text: - kwargs["append_text"] = text - else: - kwargs["backspace"] = True - - set_line_edit_focus(self._active_widget, **kwargs) + if self._active_widget is self._text_input: + self._active_widget.set_text_filter(text) def set_filter_item( self, @@ -707,10 +801,10 @@ class FilterValuePopup(QtWidgets.QWidget): else: if value is None: value = "" - self._text_input.setPlaceholderText( + self._text_input.set_placeholder_text( filter_def.placeholder or "" ) - self._text_input.setText(value) + self._text_input.set_value(value) self._active_widget = self._text_input elif filter_def.filter_type == "list": @@ -750,26 +844,27 @@ class FilterValuePopup(QtWidgets.QWidget): def get_value(self): """Get the value from the active widget.""" if self._active_widget is self._text_input: - return self._text_input.text() + 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 == self._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 _text_confirmed(self): - self.close() - 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) @@ -942,6 +1037,7 @@ class FiltersBar(BaseClickableFrame): 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 @@ -978,6 +1074,10 @@ class FiltersBar(BaseClickableFrame): 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 not None: From 42808409894750c8494a07af887146fbaf8ef9f1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 18 Jun 2025 10:50:52 +0200 Subject: [PATCH 59/59] added wheel scrolling --- .../ayon_core/tools/loader/ui/search_bar.py | 47 ++++++++++++++++--- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index 1e74426949..ab673df1ac 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -925,6 +925,24 @@ class FiltersBar(BaseClickableFrame): 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 @@ -1009,6 +1027,18 @@ class FiltersBar(BaseClickableFrame): 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() @@ -1080,10 +1110,13 @@ class FiltersBar(BaseClickableFrame): def _on_item_close_requested(self, name): widget = self._widgets_by_name.pop(name, None) - if widget is not None: - idx = self._filters_layout.indexOf(widget) - if idx > -1: - self._filters_layout.takeAt(idx) - widget.setVisible(False) - widget.deleteLater() - self.filter_changed.emit(name) + 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()