From 172fc27fa00fef89df35adc4a73ff913ba85ce85 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 3 Apr 2025 11:49:04 +0200 Subject: [PATCH 001/103] remove applications action logic --- client/ayon_core/tools/launcher/abstract.py | 16 -- client/ayon_core/tools/launcher/control.py | 5 - .../tools/launcher/models/actions.py | 255 +----------------- .../tools/launcher/ui/actions_widget.py | 73 +---- 4 files changed, 8 insertions(+), 341 deletions(-) diff --git a/client/ayon_core/tools/launcher/abstract.py b/client/ayon_core/tools/launcher/abstract.py index ea0842f24d..9189418c1c 100644 --- a/client/ayon_core/tools/launcher/abstract.py +++ b/client/ayon_core/tools/launcher/abstract.py @@ -307,22 +307,6 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): """ pass - @abstractmethod - def set_application_force_not_open_workfile( - self, project_name, folder_id, task_id, action_ids, enabled - ): - """This is application action related to force not open last workfile. - - Args: - project_name (Union[str, None]): Project name. - folder_id (Union[str, None]): Folder id. - task_id (Union[str, None]): Task id. - action_ids (Iterable[str]): Action identifiers. - enabled (bool): New value of force not open workfile. - - """ - pass - @abstractmethod def refresh(self): """Refresh everything, models, ui etc. diff --git a/client/ayon_core/tools/launcher/control.py b/client/ayon_core/tools/launcher/control.py index 45cb2b7945..671c831505 100644 --- a/client/ayon_core/tools/launcher/control.py +++ b/client/ayon_core/tools/launcher/control.py @@ -135,12 +135,7 @@ class BaseLauncherController( return self._actions_model.get_action_items( project_name, folder_id, task_id) - def set_application_force_not_open_workfile( - self, project_name, folder_id, task_id, action_ids, enabled ): - self._actions_model.set_application_force_not_open_workfile( - project_name, folder_id, task_id, action_ids, enabled - ) def trigger_action(self, project_name, folder_id, task_id, identifier): self._actions_model.trigger_action( diff --git a/client/ayon_core/tools/launcher/models/actions.py b/client/ayon_core/tools/launcher/models/actions.py index e1612e2b9f..1676ab6ec3 100644 --- a/client/ayon_core/tools/launcher/models/actions.py +++ b/client/ayon_core/tools/launcher/models/actions.py @@ -1,117 +1,12 @@ import os from ayon_core import resources -from ayon_core.lib import Logger, AYONSettingsRegistry from ayon_core.addon import AddonsManager from ayon_core.pipeline.actions import ( discover_launcher_actions, - LauncherAction, LauncherActionSelection, register_launcher_action_path, ) -from ayon_core.pipeline.workfile import should_use_last_workfile_on_launch - -try: - # Available since applications addon 0.2.4 - from ayon_applications.action import ApplicationAction -except ImportError: - # Backwards compatibility from 0.3.3 (24/06/10) - # TODO: Remove in future releases - class ApplicationAction(LauncherAction): - """Action to launch an application. - - Application action based on 'ApplicationManager' system. - - Handling of applications in launcher is not ideal and should be - completely redone from scratch. This is just a temporary solution - to keep backwards compatibility with AYON launcher. - - Todos: - Move handling of errors to frontend. - """ - - # Application object - application = None - # Action attributes - name = None - label = None - label_variant = None - group = None - icon = None - color = None - order = 0 - data = {} - project_settings = {} - project_entities = {} - - _log = None - - @property - def log(self): - if self._log is None: - self._log = Logger.get_logger(self.__class__.__name__) - return self._log - - def is_compatible(self, selection): - if not selection.is_task_selected: - return False - - project_entity = self.project_entities[selection.project_name] - apps = project_entity["attrib"].get("applications") - if not apps or self.application.full_name not in apps: - return False - - project_settings = self.project_settings[selection.project_name] - only_available = project_settings["applications"]["only_available"] - if only_available and not self.application.find_executable(): - return False - return True - - def _show_message_box(self, title, message, details=None): - from qtpy import QtWidgets, QtGui - from ayon_core import style - - dialog = QtWidgets.QMessageBox() - icon = QtGui.QIcon(resources.get_ayon_icon_filepath()) - dialog.setWindowIcon(icon) - dialog.setStyleSheet(style.load_stylesheet()) - dialog.setWindowTitle(title) - dialog.setText(message) - if details: - dialog.setDetailedText(details) - dialog.exec_() - - def process(self, selection, **kwargs): - """Process the full Application action""" - - from ayon_applications import ( - ApplicationExecutableNotFound, - ApplicationLaunchFailed, - ) - - try: - self.application.launch( - project_name=selection.project_name, - folder_path=selection.folder_path, - task_name=selection.task_name, - **self.data - ) - - except ApplicationExecutableNotFound as exc: - details = exc.details - msg = exc.msg - log_msg = str(msg) - if details: - log_msg += "\n" + details - self.log.warning(log_msg) - self._show_message_box( - "Application executable not found", msg, details - ) - - except ApplicationLaunchFailed as exc: - msg = str(exc) - self.log.warning(msg, exc_info=True) - self._show_message_box("Application launch failed", msg) # class Action: @@ -160,9 +55,6 @@ class ActionItem: action if it has same 'label' and have set 'variant_label'. icon (dict[str, str]): Icon definition. order (int): Action ordering. - is_application (bool): Is action application action. - force_not_open_workfile (bool): Force not open workfile. Application - related. full_label (Optional[str]): Full label, if not set it is generated from 'label' and 'variant_label'. """ @@ -174,8 +66,6 @@ class ActionItem: variant_label, icon, order, - is_application, - force_not_open_workfile, full_label=None ): self.identifier = identifier @@ -183,8 +73,6 @@ class ActionItem: self.variant_label = variant_label self.icon = icon self.order = order - self.is_application = is_application - self.force_not_open_workfile = force_not_open_workfile self._full_label = full_label def copy(self): @@ -206,8 +94,6 @@ class ActionItem: "variant_label": self.variant_label, "icon": self.icon, "order": self.order, - "is_application": self.is_application, - "force_not_open_workfile": self.force_not_open_workfile, "full_label": self._full_label, } @@ -264,8 +150,6 @@ class ActionsModel: controller (AbstractLauncherBackend): Controller instance. """ - _not_open_workfile_reg_key = "force_not_open_workfile" - def __init__(self, controller): self._controller = controller @@ -275,8 +159,6 @@ class ActionsModel: self._actions = None self._action_items = {} - self._launcher_tool_reg = AYONSettingsRegistry("launcher_tool") - self._addons_manager = None @property @@ -294,34 +176,6 @@ class ActionsModel: self._get_action_objects() self._controller.emit_event("actions.refresh.finished") - def _should_start_last_workfile( - self, - project_name, - task_id, - identifier, - host_name, - not_open_workfile_actions - ): - if identifier in not_open_workfile_actions: - return not not_open_workfile_actions[identifier] - - task_name = None - task_type = None - if task_id is not None: - task_entity = self._controller.get_task_entity( - project_name, task_id - ) - task_name = task_entity["name"] - task_type = task_entity["taskType"] - - output = should_use_last_workfile_on_launch( - project_name, - host_name, - task_name, - task_type - ) - return output - def get_action_items(self, project_name, folder_id, task_id): """Get actions for project. @@ -332,46 +186,18 @@ class ActionsModel: Returns: list[ActionItem]: List of actions. + """ - not_open_workfile_actions = self._get_no_last_workfile_for_context( - project_name, folder_id, task_id) selection = self._prepare_selection(project_name, folder_id, task_id) output = [] action_items = self._get_action_items(project_name) for identifier, action in self._get_action_objects().items(): - if not action.is_compatible(selection): - continue + if action.is_compatible(selection): + output.append(action_items[identifier]) - action_item = action_items[identifier] - # Handling of 'force_not_open_workfile' for applications - if action_item.is_application: - action_item = action_item.copy() - start_last_workfile = self._should_start_last_workfile( - project_name, - task_id, - identifier, - action.application.host_name, - not_open_workfile_actions - ) - action_item.force_not_open_workfile = ( - not start_last_workfile - ) - - output.append(action_item) return output - def set_application_force_not_open_workfile( - self, project_name, folder_id, task_id, action_ids, enabled ): - no_workfile_reg_data = self._get_no_last_workfile_reg_data() - project_data = no_workfile_reg_data.setdefault(project_name, {}) - folder_data = project_data.setdefault(folder_id, {}) - task_data = folder_data.setdefault(task_id, {}) - for action_id in action_ids: - task_data[action_id] = enabled - self._launcher_tool_reg.set_item( - self._not_open_workfile_reg_key, no_workfile_reg_data - ) def trigger_action(self, project_name, folder_id, task_id, identifier): selection = self._prepare_selection(project_name, folder_id, task_id) @@ -390,18 +216,6 @@ class ActionsModel: "full_label": action_label, } ) - if isinstance(action, ApplicationAction): - per_action = self._get_no_last_workfile_for_context( - project_name, folder_id, task_id - ) - start_last_workfile = self._should_start_last_workfile( - project_name, - task_id, - identifier, - action.application.host_name, - per_action - ) - action.data["start_last_workfile"] = start_last_workfile action.process(selection) except Exception as exc: @@ -424,27 +238,6 @@ class ActionsModel: self._addons_manager = AddonsManager() return self._addons_manager - def _get_no_last_workfile_reg_data(self): - try: - no_workfile_reg_data = self._launcher_tool_reg.get_item( - self._not_open_workfile_reg_key) - except ValueError: - no_workfile_reg_data = {} - self._launcher_tool_reg.set_item( - self._not_open_workfile_reg_key, no_workfile_reg_data) - return no_workfile_reg_data - - def _get_no_last_workfile_for_context( - self, project_name, folder_id, task_id - ): - not_open_workfile_reg_data = self._get_no_last_workfile_reg_data() - return ( - not_open_workfile_reg_data - .get(project_name, {}) - .get(folder_id, {}) - .get(task_id, {}) - ) - def _prepare_selection(self, project_name, folder_id, task_id): project_entity = None if project_name: @@ -470,7 +263,6 @@ class ActionsModel: register_launcher_action_path(path) self._discovered_actions = ( discover_launcher_actions() - + self._get_applications_action_classes() ) return self._discovered_actions @@ -498,10 +290,9 @@ class ActionsModel: action_items = {} for identifier, action in self._get_action_objects().items(): - is_application = isinstance(action, ApplicationAction) # Backwards compatibility from 0.3.3 (24/06/10) # TODO: Remove in future releases - if is_application and hasattr(action, "project_settings"): + if hasattr(action, "project_settings"): action.project_entities[project_name] = project_entity action.project_settings[project_name] = project_settings @@ -515,45 +306,7 @@ class ActionsModel: variant_label, icon, action.order, - is_application, - False ) action_items[identifier] = item self._action_items[project_name] = action_items return action_items - - def _get_applications_action_classes(self): - addons_manager = self._get_addons_manager() - applications_addon = addons_manager.get_enabled_addon("applications") - if hasattr(applications_addon, "get_applications_action_classes"): - return applications_addon.get_applications_action_classes() - - # Backwards compatibility from 0.3.3 (24/06/10) - # TODO: Remove in future releases - actions = [] - if applications_addon is None: - return actions - - manager = applications_addon.get_applications_manager() - for full_name, application in manager.applications.items(): - if not application.enabled: - continue - - action = type( - "app_{}".format(full_name), - (ApplicationAction,), - { - "identifier": "application.{}".format(full_name), - "application": application, - "name": application.name, - "label": application.group.label, - "label_variant": application.label, - "group": None, - "icon": application.icon, - "color": getattr(application, "color", None), - "order": getattr(application, "order", None) or 0, - "data": {} - } - ) - actions.append(action) - return actions diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index c64d718172..5131501074 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -11,12 +11,10 @@ from .resources import get_options_image_path ANIMATION_LEN = 7 ACTION_ID_ROLE = QtCore.Qt.UserRole + 1 -ACTION_IS_APPLICATION_ROLE = QtCore.Qt.UserRole + 2 -ACTION_IS_GROUP_ROLE = QtCore.Qt.UserRole + 3 -ACTION_SORT_ROLE = QtCore.Qt.UserRole + 4 -ANIMATION_START_ROLE = QtCore.Qt.UserRole + 5 -ANIMATION_STATE_ROLE = QtCore.Qt.UserRole + 6 -FORCE_NOT_OPEN_WORKFILE_ROLE = QtCore.Qt.UserRole + 7 +ACTION_IS_GROUP_ROLE = QtCore.Qt.UserRole + 2 +ACTION_SORT_ROLE = QtCore.Qt.UserRole + 3 +ANIMATION_START_ROLE = QtCore.Qt.UserRole + 4 +ANIMATION_STATE_ROLE = QtCore.Qt.UserRole + 5 def _variant_label_sort_getter(action_item): @@ -144,11 +142,6 @@ class ActionsQtModel(QtGui.QStandardItemModel): item.setData(icon, QtCore.Qt.DecorationRole) item.setData(is_group, ACTION_IS_GROUP_ROLE) item.setData(action_item.order, ACTION_SORT_ROLE) - item.setData( - action_item.is_application, ACTION_IS_APPLICATION_ROLE) - item.setData( - action_item.force_not_open_workfile, - FORCE_NOT_OPEN_WORKFILE_ROLE) items_by_id[action_item.identifier] = item action_items_by_id[action_item.identifier] = action_item @@ -263,13 +256,6 @@ class ActionDelegate(QtWidgets.QStyledItemDelegate): super(ActionDelegate, self).paint(painter, option, index) - if index.data(FORCE_NOT_OPEN_WORKFILE_ROLE): - rect = QtCore.QRectF( - option.rect.x(), option.rect.y() + option.rect.height(), 5, 5) - painter.setPen(QtCore.Qt.NoPen) - painter.setBrush(QtGui.QColor(200, 0, 0)) - painter.drawEllipse(rect) - if not index.data(ACTION_IS_GROUP_ROLE): return @@ -360,14 +346,11 @@ class ActionsWidget(QtWidgets.QWidget): animation_timer.timeout.connect(self._on_animation) view.clicked.connect(self._on_clicked) - view.customContextMenuRequested.connect(self._on_context_menu) model.refreshed.connect(self._on_model_refresh) self._animated_items = set() self._animation_timer = animation_timer - self._context_menu = None - self._flick = flick self._view = view self._model = model @@ -416,54 +399,6 @@ class ActionsWidget(QtWidgets.QWidget): self._animated_items.add(action_id) self._animation_timer.start() - def _on_context_menu(self, point): - """Creates menu to force skip opening last workfile.""" - index = self._view.indexAt(point) - if not index.isValid(): - return - - if not index.data(ACTION_IS_APPLICATION_ROLE): - return - - menu = QtWidgets.QMenu(self._view) - checkbox = QtWidgets.QCheckBox( - "Skip opening last workfile.", menu) - if index.data(FORCE_NOT_OPEN_WORKFILE_ROLE): - checkbox.setChecked(True) - - action_id = index.data(ACTION_ID_ROLE) - is_group = index.data(ACTION_IS_GROUP_ROLE) - if is_group: - action_items = self._model.get_group_items(action_id) - else: - action_items = [self._model.get_action_item_by_id(action_id)] - action_ids = {action_item.identifier for action_item in action_items} - checkbox.stateChanged.connect( - lambda: self._on_checkbox_changed( - action_ids, checkbox.isChecked() - ) - ) - action = QtWidgets.QWidgetAction(menu) - action.setDefaultWidget(checkbox) - - menu.addAction(action) - - self._context_menu = menu - global_point = self.mapToGlobal(point) - menu.exec_(global_point) - self._context_menu = None - - def _on_checkbox_changed(self, action_ids, is_checked): - if self._context_menu is not None: - self._context_menu.close() - - project_name = self._model.get_selected_project_name() - folder_id = self._model.get_selected_folder_id() - task_id = self._model.get_selected_task_id() - self._controller.set_application_force_not_open_workfile( - project_name, folder_id, task_id, action_ids, is_checked) - self._model.refresh() - def _on_clicked(self, index): if not index or not index.isValid(): return From 05ba75c5e928c22267da9cb4435ea1d341e92f89 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 3 Apr 2025 11:54:39 +0200 Subject: [PATCH 002/103] added more options how to icon can be defined --- client/ayon_core/tools/utils/lib.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/client/ayon_core/tools/utils/lib.py b/client/ayon_core/tools/utils/lib.py index 4b303c0143..424e04cbd0 100644 --- a/client/ayon_core/tools/utils/lib.py +++ b/client/ayon_core/tools/utils/lib.py @@ -1,11 +1,13 @@ import os import sys +import io import contextlib import collections import traceback from functools import partial from typing import Union, Any +import ayon_api from qtpy import QtWidgets, QtCore, QtGui import qtawesome import qtmaterialsymbols @@ -485,6 +487,9 @@ class _IconsCache: if isinstance(color, QtGui.QColor): color = color.name() parts = [icon_type, icon_def["name"] or "", color] + + elif icon_type in {"url", "ayon_url"}: + parts = [icon_type, icon_def["url"]] return "|".join(parts) @classmethod @@ -517,6 +522,18 @@ class _IconsCache: if qtmaterialsymbols.get_icon_name_char(icon_name) is not None: icon = qtmaterialsymbols.get_icon(icon_name, icon_color) + elif icon_type == "url": + url = icon_def["url"] + icon = QtGui.QPixmap(url) + + elif icon_type == "ayon_url": + url = ayon_api.get_base_url() + icon_def["url"] + stream = io.BytesIO() + ayon_api.download_file_to_stream(url, stream) + pix = QtGui.QPixmap() + pix.loadFromData(stream.getvalue()) + icon = QtGui.QIcon(pix) + if icon is None: icon = cls.get_default() cls._cache[cache_key] = icon From 9123994881100c2864399938c2480213eb219bde Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 3 Apr 2025 11:55:13 +0200 Subject: [PATCH 003/103] added baseic way to show webactions --- .../tools/launcher/models/actions.py | 83 ++++++++++++++++++- 1 file changed, 81 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/launcher/models/actions.py b/client/ayon_core/tools/launcher/models/actions.py index 1676ab6ec3..e4ac6cdc1e 100644 --- a/client/ayon_core/tools/launcher/models/actions.py +++ b/client/ayon_core/tools/launcher/models/actions.py @@ -1,6 +1,9 @@ import os +import ayon_api + from ayon_core import resources +from ayon_core.lib import Logger, NestedCacheItem, CacheItem from ayon_core.addon import AddonsManager from ayon_core.pipeline.actions import ( discover_launcher_actions, @@ -48,6 +51,7 @@ class ActionItem: Get rid of application specific logic. Args: + action_type (Literal["webaction", "local"]): Type of action. identifier (str): Unique identifier of action item. label (str): Action label. variant_label (Union[str, None]): Variant label, full label is @@ -55,24 +59,32 @@ class ActionItem: action if it has same 'label' and have set 'variant_label'. icon (dict[str, str]): Icon definition. order (int): Action ordering. + addon_name (str): Addon name. + addon_version (str): Addon version. full_label (Optional[str]): Full label, if not set it is generated from 'label' and 'variant_label'. """ def __init__( self, + action_type, identifier, label, variant_label, icon, order, + addon_name=None, + addon_version=None, full_label=None ): + self.action_type = action_type self.identifier = identifier self.label = label self.variant_label = variant_label self.icon = icon self.order = order + self.addon_name = addon_name + self.addon_version = addon_version self._full_label = full_label def copy(self): @@ -158,6 +170,9 @@ class ActionsModel: self._discovered_actions = None self._actions = None self._action_items = {} + self._webaction_items = NestedCacheItem( + levels=2, default_factory=list + ) self._addons_manager = None @@ -194,12 +209,13 @@ class ActionsModel: for identifier, action in self._get_action_objects().items(): if action.is_compatible(selection): output.append(action_items[identifier]) + output.extend(self._get_webactions(selection)) return output + def trigger_action( + self, acton_type, project_name, folder_id, task_id, identifier ): - - def trigger_action(self, project_name, folder_id, task_id, identifier): selection = self._prepare_selection(project_name, folder_id, task_id) failed = False error_message = None @@ -251,6 +267,68 @@ class ActionsModel: project_settings=project_settings, ) + def _get_webactions(self, selection: LauncherActionSelection): + if not selection.is_project_selected: + return [] + + entity_type = None + entity_id = None + entity_subtypes = [] + if selection.is_task_selected: + entity_type = "task" + entity_id = selection.task_entity["id"] + entity_subtypes = [selection.task_entity["taskType"]] + + elif selection.is_folder_selected: + entity_type = "folder" + entity_id = selection.folder_entity["id"] + entity_subtypes = [selection.folder_entity["folderType"]] + + entity_ids = [] + if entity_id: + entity_ids.append(entity_id) + + project_name = selection.project_name + cache: CacheItem = self._webaction_items[project_name][entity_id] + if cache.is_valid: + return cache.get_data() + + context = { + "projectName": project_name, + "entityType": entity_type, + "entitySubtypes": entity_subtypes, + "entityIds": entity_ids, + } + response = ayon_api.post("actions/list", **context) + try: + response.raise_for_status() + except Exception: + self.log.warning("Failed to collect webactions.", exc_info=True) + return [] + + action_items = [] + for action in response.data["actions"]: + # NOTE Settings variant may be important for triggering? + # - action["variant"] + icon = action["icon"] + if icon["type"] == "url" and icon["url"].startswith("/"): + icon["type"] = "ayon_url" + action_items.append(ActionItem( + "webaction", + action["identifier"], + # action["category"], + action["label"], + None, + action["icon"], + action["order"], + action["addonName"], + action["addonVersion"], + )) + + cache.update_data(action_items) + + return cache.get_data() + def _get_discovered_action_classes(self): if self._discovered_actions is None: # NOTE We don't need to register the paths, but that would @@ -301,6 +379,7 @@ class ActionsModel: icon = get_action_icon(action) item = ActionItem( + "local", identifier, label, variant_label, From 698fef28330ce277ec9310535c1bb9277443dc5c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 4 Apr 2025 09:43:24 +0200 Subject: [PATCH 004/103] added helper function to get settings variant --- client/ayon_core/lib/__init__.py | 2 ++ client/ayon_core/lib/ayon_info.py | 24 ++++++++++++++++++++---- client/ayon_core/settings/lib.py | 9 ++------- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/client/ayon_core/lib/__init__.py b/client/ayon_core/lib/__init__.py index 92c3966e77..ad906774d3 100644 --- a/client/ayon_core/lib/__init__.py +++ b/client/ayon_core/lib/__init__.py @@ -132,6 +132,7 @@ from .ayon_info import ( is_staging_enabled, is_dev_mode_enabled, is_in_tests, + get_settings_variant, ) terminal = Terminal @@ -242,4 +243,5 @@ __all__ = [ "is_staging_enabled", "is_dev_mode_enabled", "is_in_tests", + "get_settings_variant", ] diff --git a/client/ayon_core/lib/ayon_info.py b/client/ayon_core/lib/ayon_info.py index 7e194a824e..1a7e4cca76 100644 --- a/client/ayon_core/lib/ayon_info.py +++ b/client/ayon_core/lib/ayon_info.py @@ -78,15 +78,15 @@ def is_using_ayon_console(): return "ayon_console" in executable_filename -def is_headless_mode_enabled(): +def is_headless_mode_enabled() -> bool: return os.getenv("AYON_HEADLESS_MODE") == "1" -def is_staging_enabled(): +def is_staging_enabled() -> bool: return os.getenv("AYON_USE_STAGING") == "1" -def is_in_tests(): +def is_in_tests() -> bool: """Process is running in automatic tests mode. Returns: @@ -96,7 +96,7 @@ def is_in_tests(): return os.environ.get("AYON_IN_TESTS") == "1" -def is_dev_mode_enabled(): +def is_dev_mode_enabled() -> bool: """Dev mode is enabled in AYON. Returns: @@ -106,6 +106,22 @@ def is_dev_mode_enabled(): return os.getenv("AYON_USE_DEV") == "1" +def get_settings_variant() -> str: + """Get AYON settings variant. + + Returns: + str: Settings variant. + + """ + if is_dev_mode_enabled(): + return os.environ["AYON_BUNDLE_NAME"] + + if is_staging_enabled(): + return "staging" + + return "production" + + def get_ayon_info(): executable_args = get_ayon_launcher_args() if is_running_from_build(): diff --git a/client/ayon_core/settings/lib.py b/client/ayon_core/settings/lib.py index aa56fa8326..72af07799f 100644 --- a/client/ayon_core/settings/lib.py +++ b/client/ayon_core/settings/lib.py @@ -56,14 +56,9 @@ class _AyonSettingsCache: @classmethod def _get_variant(cls): if _AyonSettingsCache.variant is None: - from ayon_core.lib import is_staging_enabled, is_dev_mode_enabled - - variant = "production" - if is_dev_mode_enabled(): - variant = cls._get_bundle_name() - elif is_staging_enabled(): - variant = "staging" + from ayon_core.lib import get_settings_variant + variant = get_settings_variant() # Cache variant _AyonSettingsCache.variant = variant From 9a816436786cb3b4387cf4d7e09692329982e6ee Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 4 Apr 2025 09:45:23 +0200 Subject: [PATCH 005/103] added addon name and version to action data --- client/ayon_core/tools/launcher/abstract.py | 14 ++++++-- client/ayon_core/tools/launcher/control.py | 22 ++++++++++--- .../tools/launcher/models/actions.py | 9 ++++- .../tools/launcher/ui/actions_widget.py | 33 +++++++++++++++---- 4 files changed, 65 insertions(+), 13 deletions(-) diff --git a/client/ayon_core/tools/launcher/abstract.py b/client/ayon_core/tools/launcher/abstract.py index 9189418c1c..372e84c149 100644 --- a/client/ayon_core/tools/launcher/abstract.py +++ b/client/ayon_core/tools/launcher/abstract.py @@ -295,14 +295,24 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def trigger_action(self, project_name, folder_id, task_id, action_id): + def trigger_action( + self, + action_type, + action_id, + project_name, + folder_id, + task_id, + ): """Trigger action on given context. Args: + action_type (Literal["webaction", "local"]): Action type. + action_id (str): Action identifier. project_name (Union[str, None]): Project name. folder_id (Union[str, None]): Folder id. task_id (Union[str, None]): Task id. - action_id (str): Action identifier. + addon_name (Union[str, None]): Addon name. + addon_version (Union[str, None]): Addon version. """ pass diff --git a/client/ayon_core/tools/launcher/control.py b/client/ayon_core/tools/launcher/control.py index 671c831505..5b7eb1ab4a 100644 --- a/client/ayon_core/tools/launcher/control.py +++ b/client/ayon_core/tools/launcher/control.py @@ -32,7 +32,7 @@ class BaseLauncherController( @property def event_system(self): - """Inner event system for workfiles tool controller. + """Inner event system for launcher tool controller. Is used for communication with UI. Event system is created on demand. @@ -135,11 +135,25 @@ class BaseLauncherController( return self._actions_model.get_action_items( project_name, folder_id, task_id) + def trigger_action( + self, + action_type, + identifier, + project_name, + folder_id, + task_id, + addon_name, + addon_version, ): - - def trigger_action(self, project_name, folder_id, task_id, identifier): self._actions_model.trigger_action( - project_name, folder_id, task_id, identifier) + action_type, + identifier, + project_name, + folder_id, + task_id, + addon_name, + addon_version, + ) # General methods def refresh(self): diff --git a/client/ayon_core/tools/launcher/models/actions.py b/client/ayon_core/tools/launcher/models/actions.py index e4ac6cdc1e..7ab9b33ecd 100644 --- a/client/ayon_core/tools/launcher/models/actions.py +++ b/client/ayon_core/tools/launcher/models/actions.py @@ -214,7 +214,14 @@ class ActionsModel: return output def trigger_action( - self, acton_type, project_name, folder_id, task_id, identifier + self, + acton_type, + identifier, + project_name, + folder_id, + task_id, + addon_name, + addon_version, ): selection = self._prepare_selection(project_name, folder_id, task_id) failed = False diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 5131501074..f46af4bf61 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -11,10 +11,13 @@ from .resources import get_options_image_path ANIMATION_LEN = 7 ACTION_ID_ROLE = QtCore.Qt.UserRole + 1 -ACTION_IS_GROUP_ROLE = QtCore.Qt.UserRole + 2 -ACTION_SORT_ROLE = QtCore.Qt.UserRole + 3 -ANIMATION_START_ROLE = QtCore.Qt.UserRole + 4 -ANIMATION_STATE_ROLE = QtCore.Qt.UserRole + 5 +ACTION_TYPE_ROLE = QtCore.Qt.UserRole + 2 +ACTION_IS_GROUP_ROLE = QtCore.Qt.UserRole + 3 +ACTION_SORT_ROLE = QtCore.Qt.UserRole + 4 +ACTION_ADDON_NAME_ROLE = QtCore.Qt.UserRole + 5 +ACTION_ADDON_VERSION_ROLE = QtCore.Qt.UserRole + 6 +ANIMATION_START_ROLE = QtCore.Qt.UserRole + 7 +ANIMATION_STATE_ROLE = QtCore.Qt.UserRole + 8 def _variant_label_sort_getter(action_item): @@ -141,6 +144,9 @@ class ActionsQtModel(QtGui.QStandardItemModel): item.setData(label, QtCore.Qt.DisplayRole) item.setData(icon, QtCore.Qt.DecorationRole) item.setData(is_group, ACTION_IS_GROUP_ROLE) + item.setData(action_item.action_type, ACTION_TYPE_ROLE) + item.setData(action_item.addon_name, ACTION_ADDON_NAME_ROLE) + item.setData(action_item.addon_version, ACTION_ADDON_VERSION_ROLE) item.setData(action_item.order, ACTION_SORT_ROLE) items_by_id[action_item.identifier] = item action_items_by_id[action_item.identifier] = action_item @@ -411,8 +417,17 @@ class ActionsWidget(QtWidgets.QWidget): task_id = self._model.get_selected_task_id() if not is_group: + action_type = index.data(ACTION_TYPE_ROLE) + addon_name = index.data(ACTION_ADDON_NAME_ROLE) + addon_version = index.data(ACTION_ADDON_VERSION_ROLE) self._controller.trigger_action( - project_name, folder_id, task_id, action_id + action_type, + action_id, + project_name, + folder_id, + task_id, + addon_name, + addon_version, ) self._start_animation(index) return @@ -434,6 +449,12 @@ class ActionsWidget(QtWidgets.QWidget): action_item = actions_mapping[result] self._controller.trigger_action( - project_name, folder_id, task_id, action_item.identifier + action_item.action_type, + action_item.identifier, + project_name, + folder_id, + task_id, + action_item.addon_name, + action_item.addon_version, ) self._start_animation(index) From d07e3b937241ca637e298690494235b3c8c50431 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 4 Apr 2025 09:46:41 +0200 Subject: [PATCH 006/103] implemented webaction trigger --- .../tools/launcher/models/actions.py | 116 +++++++++++++++++- 1 file changed, 114 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/launcher/models/actions.py b/client/ayon_core/tools/launcher/models/actions.py index 7ab9b33ecd..648833e421 100644 --- a/client/ayon_core/tools/launcher/models/actions.py +++ b/client/ayon_core/tools/launcher/models/actions.py @@ -1,9 +1,17 @@ import os +import platform +import subprocess +from urllib.parse import urlencode import ayon_api from ayon_core import resources -from ayon_core.lib import Logger, NestedCacheItem, CacheItem +from ayon_core.lib import ( + Logger, + NestedCacheItem, + CacheItem, + get_settings_variant, +) from ayon_core.addon import AddonsManager from ayon_core.pipeline.actions import ( discover_launcher_actions, @@ -176,6 +184,8 @@ class ActionsModel: self._addons_manager = None + self._variant = get_settings_variant() + @property def log(self): if self._log is None: @@ -223,6 +233,17 @@ class ActionsModel: addon_name, addon_version, ): + if acton_type == "webaction": + self._trigger_webaction( + identifier, + project_name, + folder_id, + task_id, + addon_name, + addon_version, + ) + return + selection = self._prepare_selection(project_name, folder_id, task_id) failed = False error_message = None @@ -306,8 +327,8 @@ class ActionsModel: "entitySubtypes": entity_subtypes, "entityIds": entity_ids, } - response = ayon_api.post("actions/list", **context) try: + response = ayon_api.post("actions/list", **context) response.raise_for_status() except Exception: self.log.warning("Failed to collect webactions.", exc_info=True) @@ -336,6 +357,97 @@ class ActionsModel: return cache.get_data() + def _trigger_webaction( + self, + identifier, + project_name, + folder_id, + task_id, + addon_name, + addon_version, + ): + entity_type = None + entity_ids = [] + if task_id: + entity_type = "task" + entity_ids.append(task_id) + elif folder_id: + entity_type = "folder" + entity_ids.append(folder_id) + + query = { + "addonName": addon_name, + "addonVersion": addon_version, + "identifier": identifier, + "variant": self._variant, + } + url = f"actions/execute?{urlencode(query)}" + context = { + "projectName": project_name, + "entityType": entity_type, + "entityIds": entity_ids, + } + + # TODO pass label in as argument? + action_label= "Webaction" + + failed = False + error_message = None + try: + self._controller.emit_event( + "action.trigger.started", + { + "identifier": identifier, + "full_label": action_label, + } + ) + response = ayon_api.post(url, **context) + response.raise_for_status() + data = response.data + if data["success"] == True: + self._handle_webaction_response(data) + else: + error_message = data["message"] + failed = True + + except Exception as exc: + self.log.warning("Action trigger failed.", exc_info=True) + failed = True + error_message = str(exc) + + self._controller.emit_event( + "action.trigger.finished", + { + "identifier": identifier, + "failed": failed, + "error_message": error_message, + "full_label": action_label, + } + ) + + def _handle_webaction_response(self, data): + response_type = data["type"] + # Nothing to do + if response_type == "server": + return + + if response_type == "launcher": + uri = data["uri"] + # There might be a better way to do this? + # Not sure if all linux distributions have 'xdg-open' available + platform_name = platform.system().lower() + if platform_name == "windows": + os.startfile(uri) + elif platform_name == "darwin": + subprocess.run(["open", uri]) + else: + subprocess.run(["xdg-open", uri]) + return + + raise Exception( + "Unknown webaction response type '{response_type}'" + ) + def _get_discovered_action_classes(self): if self._discovered_actions is None: # NOTE We don't need to register the paths, but that would From 777b835e86b7915e50408214da1f458c35247479 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 4 Apr 2025 11:07:42 +0200 Subject: [PATCH 007/103] added 'transparent' icon type --- client/ayon_core/tools/utils/lib.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/client/ayon_core/tools/utils/lib.py b/client/ayon_core/tools/utils/lib.py index 424e04cbd0..4814e625df 100644 --- a/client/ayon_core/tools/utils/lib.py +++ b/client/ayon_core/tools/utils/lib.py @@ -490,6 +490,13 @@ class _IconsCache: elif icon_type in {"url", "ayon_url"}: parts = [icon_type, icon_def["url"]] + + elif icon_type == "transparent": + size = icon_def.get("size") + if size is None: + size = 256 + parts = [icon_type, str(size)] + return "|".join(parts) @classmethod @@ -534,6 +541,14 @@ class _IconsCache: pix.loadFromData(stream.getvalue()) icon = QtGui.QIcon(pix) + elif icon_type == "transparent": + size = icon_def.get("size") + if size is None: + size = 256 + pix = QtGui.QPixmap(size, size) + pix.fill(QtCore.Qt.transparent) + icon = QtGui.QIcon(pix) + if icon is None: icon = cls.get_default() cls._cache[cache_key] = icon From 39170381a0d8255521aa1fa23ba828a538b06db2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 4 Apr 2025 11:08:01 +0200 Subject: [PATCH 008/103] handle empty icon in actions widget --- client/ayon_core/tools/launcher/ui/actions_widget.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index f46af4bf61..4ba407042d 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -128,7 +128,10 @@ class ActionsQtModel(QtGui.QStandardItemModel): action_items_by_id = {} for action_item_info in all_action_items_info: action_item, is_group = action_item_info - icon = get_qt_icon(action_item.icon) + icon_def = action_item.icon + if not icon_def: + icon_def = {"type": "transparent", "size": 256} + icon = get_qt_icon(icon_def) if is_group: label = action_item.label else: From 4d1ad759cade765f783f96515e61d47a5d9e7f26 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 4 Apr 2025 11:08:35 +0200 Subject: [PATCH 009/103] handle missing icon in action manifest --- client/ayon_core/tools/launcher/models/actions.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/tools/launcher/models/actions.py b/client/ayon_core/tools/launcher/models/actions.py index 648833e421..0834fdd5eb 100644 --- a/client/ayon_core/tools/launcher/models/actions.py +++ b/client/ayon_core/tools/launcher/models/actions.py @@ -338,16 +338,17 @@ class ActionsModel: for action in response.data["actions"]: # NOTE Settings variant may be important for triggering? # - action["variant"] - icon = action["icon"] - if icon["type"] == "url" and icon["url"].startswith("/"): + icon = action.get("icon") + if icon and icon["type"] == "url" and icon["url"].startswith("/"): icon["type"] = "ayon_url" + action_items.append(ActionItem( "webaction", action["identifier"], # action["category"], action["label"], None, - action["icon"], + icon, action["order"], action["addonName"], action["addonVersion"], From c97b58fc05acd85a5dfda2552c963ec0a6de24e2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 7 Apr 2025 14:10:39 +0200 Subject: [PATCH 010/103] fix boolean comparison --- client/ayon_core/tools/launcher/models/actions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/launcher/models/actions.py b/client/ayon_core/tools/launcher/models/actions.py index 0834fdd5eb..3e46520ae5 100644 --- a/client/ayon_core/tools/launcher/models/actions.py +++ b/client/ayon_core/tools/launcher/models/actions.py @@ -405,7 +405,7 @@ class ActionsModel: response = ayon_api.post(url, **context) response.raise_for_status() data = response.data - if data["success"] == True: + if data["success"] is True: self._handle_webaction_response(data) else: error_message = data["message"] From 1a0e099b00312a94707fb63627730593f7b0a4d4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 15 Apr 2025 17:05:22 +0200 Subject: [PATCH 011/103] added config fields into actions model --- .../tools/launcher/models/actions.py | 101 ++++++++++++++++-- 1 file changed, 94 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/tools/launcher/models/actions.py b/client/ayon_core/tools/launcher/models/actions.py index 3e46520ae5..33a72bf7ad 100644 --- a/client/ayon_core/tools/launcher/models/actions.py +++ b/client/ayon_core/tools/launcher/models/actions.py @@ -1,4 +1,5 @@ import os +import copy import platform import subprocess from urllib.parse import urlencode @@ -69,6 +70,7 @@ class ActionItem: order (int): Action ordering. addon_name (str): Addon name. addon_version (str): Addon version. + config_fields (list[dict]): Config fields for webaction. full_label (Optional[str]): Full label, if not set it is generated from 'label' and 'variant_label'. """ @@ -83,8 +85,11 @@ class ActionItem: order, addon_name=None, addon_version=None, + config_fields=None, full_label=None ): + if config_fields is None: + config_fields = [] self.action_type = action_type self.identifier = identifier self.label = label @@ -93,6 +98,7 @@ class ActionItem: self.order = order self.addon_name = addon_name self.addon_version = addon_version + self.config_fields = config_fields self._full_label = full_label def copy(self): @@ -115,6 +121,7 @@ class ActionItem: "icon": self.icon, "order": self.order, "full_label": self._full_label, + "config_fields": copy.deepcopy(self.config_fields), } @classmethod @@ -277,6 +284,72 @@ class ActionsModel: } ) + def get_action_config_values( + self, + identifier, + project_name, + folder_id, + task_id, + addon_name, + addon_version, + ): + selection = self._prepare_selection(project_name, folder_id, task_id) + if not selection.is_project_selected: + return {} + + context = self._get_webaction_context(selection) + + query = { + "addonName": addon_name, + "addonVersion": addon_version, + "identifier": identifier, + "variant": self._variant, + } + url = f"actions/config?{urlencode(query)}" + try: + response = ayon_api.post(url, **context) + response.raise_for_status() + except Exception: + self.log.warning( + "Failed to collect webaction config values.", + exc_info=True + ) + return {} + return response.data + + def set_action_config_values( + self, + identifier, + project_name, + folder_id, + task_id, + addon_name, + addon_version, + values, + ): + selection = self._prepare_selection(project_name, folder_id, task_id) + if not selection.is_project_selected: + return {} + + context = self._get_webaction_context(selection) + context["value"] = values + + query = { + "addonName": addon_name, + "addonVersion": addon_version, + "identifier": identifier, + "variant": self._variant, + } + url = f"actions/config?{urlencode(query)}" + try: + response = ayon_api.post(url, **context) + response.raise_for_status() + except Exception: + self.log.warning( + "Failed to store webaction config values.", + exc_info=True + ) + def _get_addons_manager(self): if self._addons_manager is None: self._addons_manager = AddonsManager() @@ -295,9 +368,9 @@ class ActionsModel: project_settings=project_settings, ) - def _get_webactions(self, selection: LauncherActionSelection): + def _get_webaction_context(self, selection: LauncherActionSelection): if not selection.is_project_selected: - return [] + return None entity_type = None entity_id = None @@ -317,16 +390,27 @@ class ActionsModel: entity_ids.append(entity_id) project_name = selection.project_name - cache: CacheItem = self._webaction_items[project_name][entity_id] - if cache.is_valid: - return cache.get_data() - - context = { + return { "projectName": project_name, "entityType": entity_type, "entitySubtypes": entity_subtypes, "entityIds": entity_ids, } + + def _get_webactions(self, selection: LauncherActionSelection): + if not selection.is_project_selected: + return [] + + context = self._get_webaction_context(selection) + project_name = selection.project_name + entity_id = None + if context["entityIds"]: + entity_id = context["entityIds"][0] + + cache: CacheItem = self._webaction_items[project_name][entity_id] + if cache.is_valid: + return cache.get_data() + try: response = ayon_api.post("actions/list", **context) response.raise_for_status() @@ -342,6 +426,8 @@ class ActionsModel: if icon and icon["type"] == "url" and icon["url"].startswith("/"): icon["type"] = "ayon_url" + config_fields = action.get("configFields") or [] + action_items.append(ActionItem( "webaction", action["identifier"], @@ -352,6 +438,7 @@ class ActionsModel: action["order"], action["addonName"], action["addonVersion"], + config_fields, )) cache.update_data(action_items) From 4d6978490ff75a15048fd9ed480f4e11b090aa61 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 15 Apr 2025 17:05:33 +0200 Subject: [PATCH 012/103] added new methods to controller --- client/ayon_core/tools/launcher/abstract.py | 27 +++++++++++++++ client/ayon_core/tools/launcher/control.py | 38 +++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/client/ayon_core/tools/launcher/abstract.py b/client/ayon_core/tools/launcher/abstract.py index 372e84c149..dd65c95167 100644 --- a/client/ayon_core/tools/launcher/abstract.py +++ b/client/ayon_core/tools/launcher/abstract.py @@ -302,6 +302,8 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): project_name, folder_id, task_id, + addon_name, + addon_version, ): """Trigger action on given context. @@ -317,6 +319,31 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): """ pass + @abstractmethod + def get_action_config_values( + self, + action_id, + project_name, + folder_id, + task_id, + addon_name, + addon_version, + ): + pass + + @abstractmethod + def set_action_config_values( + self, + action_id, + project_name, + folder_id, + task_id, + addon_name, + addon_version, + values, + ): + pass + @abstractmethod def refresh(self): """Refresh everything, models, ui etc. diff --git a/client/ayon_core/tools/launcher/control.py b/client/ayon_core/tools/launcher/control.py index 5b7eb1ab4a..445b17ac38 100644 --- a/client/ayon_core/tools/launcher/control.py +++ b/client/ayon_core/tools/launcher/control.py @@ -155,6 +155,44 @@ class BaseLauncherController( addon_version, ) + def get_action_config_values( + self, + action_id, + project_name, + folder_id, + task_id, + addon_name, + addon_version, + ): + return self._actions_model.get_action_config_values( + action_id, + project_name, + folder_id, + task_id, + addon_name, + addon_version, + ) + + def set_action_config_values( + self, + action_id, + project_name, + folder_id, + task_id, + addon_name, + addon_version, + values, + ): + return self._actions_model.set_action_config_values( + action_id, + project_name, + folder_id, + task_id, + addon_name, + addon_version, + values, + ) + # General methods def refresh(self): self._emit_event("controller.refresh.started") From 99a7d2f5321bc6d42cdc1e8ba3c912a6b7e3a85a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 15 Apr 2025 17:06:39 +0200 Subject: [PATCH 013/103] implemented base of action config UI --- .../tools/launcher/ui/actions_widget.py | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 4ba407042d..2f7133b863 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -1,10 +1,21 @@ import time +import uuid import collections from qtpy import QtWidgets, QtCore, QtGui +from ayon_core import style +from ayon_core.lib.attribute_definitions import ( + UILabelDef, + EnumDef, + TextDef, + BoolDef, + NumberDef, + HiddenDef, +) from ayon_core.tools.flickcharm import FlickCharm from ayon_core.tools.utils import get_qt_icon +from ayon_core.tools.attribute_defs import AttributeDefinitionsDialog from .resources import get_options_image_path @@ -168,6 +179,12 @@ class ActionsQtModel(QtGui.QStandardItemModel): self._action_items_by_id = action_items_by_id self.refreshed.emit() + def get_action_config_fields(self, action_id: str): + action_item = self._action_items_by_id.get(action_id) + if action_item is not None: + return action_item.config_fields + return None + def _on_selection_project_changed(self, event): self._selected_project_name = event["project_name"] self._selected_folder_id = None @@ -355,6 +372,7 @@ class ActionsWidget(QtWidgets.QWidget): animation_timer.timeout.connect(self._on_animation) view.clicked.connect(self._on_clicked) + view.customContextMenuRequested.connect(self._on_context_menu) model.refreshed.connect(self._on_model_refresh) self._animated_items = set() @@ -365,6 +383,8 @@ class ActionsWidget(QtWidgets.QWidget): self._model = model self._proxy_model = proxy_model + self._config_widget = None + self._set_row_height(1) def refresh(self): @@ -461,3 +481,114 @@ class ActionsWidget(QtWidgets.QWidget): action_item.addon_version, ) self._start_animation(index) + + def _on_context_menu(self, point): + """Creates menu to force skip opening last workfile.""" + index = self._view.indexAt(point) + if not index.isValid(): + return + + action_id = index.data(ACTION_ID_ROLE) + if not action_id: + return + + config_fields = self._model.get_action_config_fields(action_id) + if not config_fields: + return + + values = self._controller.get_action_config_values( + action_id, + project_name=self._model.get_selected_project_name(), + folder_id=self._model.get_selected_folder_id(), + task_id=self._model.get_selected_task_id(), + addon_name=index.data(ACTION_ADDON_NAME_ROLE), + addon_version=index.data(ACTION_ADDON_VERSION_ROLE), + ) + + dialog = self._create_config_dialog(config_fields) + result = dialog.exec_() + if result == QtWidgets.QDialog.Rejected: + return + new_values = dialog.get_values() + self._controller.set_action_config_values( + action_id, + project_name=self._model.get_selected_project_name(), + folder_id=self._model.get_selected_folder_id(), + task_id=self._model.get_selected_task_id(), + addon_name=index.data(ACTION_ADDON_NAME_ROLE), + addon_version=index.data(ACTION_ADDON_VERSION_ROLE), + values=new_values, + ) + + def _create_config_dialog(self, config_fields): + """Creates config widget.""" + """ + type="label" text + type="text" label value placeholder regex multiline syntax + type="boolean" label value + type="select" label value options + type="multiselect" label value options + type="hidden" value + type="integer" label value placeholder min max + type="float" label value placeholder min max + """ + attr_defs = [] + for config_field in config_fields: + field_type = config_field["type"] + attr_def = None + if field_type == "label": + attr_def = UILabelDef( + config_field["text"], key=uuid.uuid4().hex + ) + elif field_type == "boolean": + attr_def = BoolDef( + config_field["name"], + default=config_field["value"], + label=config_field["label"], + ) + elif field_type == "text": + attr_def = TextDef( + config_field["name"], + default=config_field["value"], + label=config_field["label"], + placeholder=config_field["placeholder"], + multiline=config_field["multiline"], + regex=config_field["regex"], + # syntax=config_field["syntax"], + ) + elif field_type in ("integer", "float"): + attr_def = NumberDef( + config_field["name"], + default=config_field["value"], + label=config_field["label"], + decimals=0 if field_type == "integer" else 5, + placeholder=config_field["placeholder"], + min_value=config_field.get("min"), + max_value=config_field.get("max"), + ) + elif field_type in ("select", "multiselect"): + attr_def = EnumDef( + config_field["name"], + default=config_field["value"], + label=config_field["label"], + options=config_field["options"], + multi_select=field_type == "multiselect", + ) + elif field_type == "hidden": + attr_def = HiddenDef( + config_field["name"], + default=config_field["value"], + ) + + if attr_def is None: + print(f"Unknown config field type: {field_type}") + attr_def = UILabelDef( + f"Unknown field type '{field_type}", + key=uuid.uuid4().hex + ) + attr_defs.append(attr_def) + + dialog = AttributeDefinitionsDialog(attr_defs, parent=self) + dialog.setWindowTitle("Action Config") + dialog.setStyleSheet(style.load_stylesheet()) + return dialog From 6dcd8f29d0564a68f8a24b4c1737a0da290f5929 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 15 Apr 2025 17:06:52 +0200 Subject: [PATCH 014/103] implemented helper method to set values of dialog --- client/ayon_core/tools/attribute_defs/dialog.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/attribute_defs/dialog.py b/client/ayon_core/tools/attribute_defs/dialog.py index ef717d576a..53405105f5 100644 --- a/client/ayon_core/tools/attribute_defs/dialog.py +++ b/client/ayon_core/tools/attribute_defs/dialog.py @@ -5,7 +5,7 @@ from .widgets import AttributeDefinitionsWidget class AttributeDefinitionsDialog(QtWidgets.QDialog): def __init__(self, attr_defs, parent=None): - super(AttributeDefinitionsDialog, self).__init__(parent) + super().__init__(parent) attrs_widget = AttributeDefinitionsWidget(attr_defs, self) @@ -31,3 +31,6 @@ class AttributeDefinitionsDialog(QtWidgets.QDialog): def get_values(self): return self._attrs_widget.current_value() + + def set_values(self, values): + self._attrs_widget.set_value(values) From 63408d7d8307b530ea303cf51aaa52f26cb3c725 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 15 Apr 2025 17:07:20 +0200 Subject: [PATCH 015/103] use current values for UI --- client/ayon_core/tools/launcher/ui/actions_widget.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 2f7133b863..0dad290c0a 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -506,6 +506,7 @@ class ActionsWidget(QtWidgets.QWidget): ) dialog = self._create_config_dialog(config_fields) + dialog.set_values(values) result = dialog.exec_() if result == QtWidgets.QDialog.Rejected: return From 79b09e6a7be9b186c01a1991bf68f81f25e876ba Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 15 Apr 2025 17:18:01 +0200 Subject: [PATCH 016/103] move types to docstring --- .../tools/launcher/ui/actions_widget.py | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 0dad290c0a..99fd249918 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -522,16 +522,19 @@ class ActionsWidget(QtWidgets.QWidget): ) def _create_config_dialog(self, config_fields): - """Creates config widget.""" - """ - type="label" text - type="text" label value placeholder regex multiline syntax - type="boolean" label value - type="select" label value options - type="multiselect" label value options - type="hidden" value - type="integer" label value placeholder min max - type="float" label value placeholder min max + """Creates config widget. + + Types: + label - 'text' + text - 'label', 'value', 'placeholder', 'regex', + 'multiline', 'syntax' + boolean - 'label', 'value' + select - 'label', 'value', 'options' + multiselect - 'label', 'value', 'options' + hidden - 'value' + integer - 'label', 'value', 'placeholder', 'min', 'max' + float - 'label', 'value', 'placeholder', 'min', 'max' + """ attr_defs = [] for config_field in config_fields: From 95bf29263da48f913a146c847bc8e961e3bc9827 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 16 Apr 2025 10:33:59 +0200 Subject: [PATCH 017/103] fix spacing --- client/ayon_core/tools/launcher/models/actions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/launcher/models/actions.py b/client/ayon_core/tools/launcher/models/actions.py index 33a72bf7ad..f80f2cc443 100644 --- a/client/ayon_core/tools/launcher/models/actions.py +++ b/client/ayon_core/tools/launcher/models/actions.py @@ -477,7 +477,7 @@ class ActionsModel: } # TODO pass label in as argument? - action_label= "Webaction" + action_label = "Webaction" failed = False error_message = None From ce1375443f7728f9fd6b199b0912610a23dc95b2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 16 Apr 2025 14:45:47 +0200 Subject: [PATCH 018/103] set 'referer' header --- client/ayon_core/tools/launcher/models/actions.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/launcher/models/actions.py b/client/ayon_core/tools/launcher/models/actions.py index f80f2cc443..9e566f5958 100644 --- a/client/ayon_core/tools/launcher/models/actions.py +++ b/client/ayon_core/tools/launcher/models/actions.py @@ -489,7 +489,11 @@ class ActionsModel: "full_label": action_label, } ) - response = ayon_api.post(url, **context) + + conn = ayon_api.get_server_api_connection() + headers = conn.get_headers() + headers["referer"] = conn.get_base_url() + response = ayon_api.raw_post(url, headers=headers, json=context) response.raise_for_status() data = response.data if data["success"] is True: From 867dae4c6af3fd4e64a6727b22e6a0991311b2b2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 22 Apr 2025 12:12:24 +0200 Subject: [PATCH 019/103] pass action label to trigger action --- client/ayon_core/tools/launcher/abstract.py | 2 + client/ayon_core/tools/launcher/control.py | 2 + .../tools/launcher/models/actions.py | 6 +-- .../tools/launcher/ui/actions_widget.py | 52 ++++++++++--------- 4 files changed, 34 insertions(+), 28 deletions(-) diff --git a/client/ayon_core/tools/launcher/abstract.py b/client/ayon_core/tools/launcher/abstract.py index dd65c95167..c62faa28db 100644 --- a/client/ayon_core/tools/launcher/abstract.py +++ b/client/ayon_core/tools/launcher/abstract.py @@ -297,6 +297,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): @abstractmethod def trigger_action( self, + action_label, action_type, action_id, project_name, @@ -308,6 +309,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): """Trigger action on given context. Args: + action_label (str): Action label. action_type (Literal["webaction", "local"]): Action type. action_id (str): Action identifier. project_name (Union[str, None]): Project name. diff --git a/client/ayon_core/tools/launcher/control.py b/client/ayon_core/tools/launcher/control.py index 445b17ac38..cdd8806421 100644 --- a/client/ayon_core/tools/launcher/control.py +++ b/client/ayon_core/tools/launcher/control.py @@ -137,6 +137,7 @@ class BaseLauncherController( def trigger_action( self, + action_label, action_type, identifier, project_name, @@ -146,6 +147,7 @@ class BaseLauncherController( addon_version, ): self._actions_model.trigger_action( + action_label, action_type, identifier, project_name, diff --git a/client/ayon_core/tools/launcher/models/actions.py b/client/ayon_core/tools/launcher/models/actions.py index 9e566f5958..e36143f9df 100644 --- a/client/ayon_core/tools/launcher/models/actions.py +++ b/client/ayon_core/tools/launcher/models/actions.py @@ -232,6 +232,7 @@ class ActionsModel: def trigger_action( self, + action_label, acton_type, identifier, project_name, @@ -242,6 +243,7 @@ class ActionsModel: ): if acton_type == "webaction": self._trigger_webaction( + action_label, identifier, project_name, folder_id, @@ -447,6 +449,7 @@ class ActionsModel: def _trigger_webaction( self, + action_label, identifier, project_name, folder_id, @@ -476,9 +479,6 @@ class ActionsModel: "entityIds": entity_ids, } - # TODO pass label in as argument? - action_label = "Webaction" - failed = False error_message = None try: diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 99fd249918..69b5f76c29 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -438,23 +438,36 @@ class ActionsWidget(QtWidgets.QWidget): project_name = self._model.get_selected_project_name() folder_id = self._model.get_selected_folder_id() task_id = self._model.get_selected_task_id() + if is_group: + action_item = self._show_menu_on_group(action_id) + if action_item is None: + return - if not is_group: + action_id = action_item.identifier + action_label = action_item.full_label + action_type = action_item.action_type + addon_name = action_item.addon_name + addon_version = action_item.addon_version + else: + action_label = index.data(QtCore.Qt.DisplayRole) action_type = index.data(ACTION_TYPE_ROLE) addon_name = index.data(ACTION_ADDON_NAME_ROLE) addon_version = index.data(ACTION_ADDON_VERSION_ROLE) - self._controller.trigger_action( - action_type, - action_id, - project_name, - folder_id, - task_id, - addon_name, - addon_version, - ) - self._start_animation(index) - return + self._controller.trigger_action( + action_label, + action_type, + action_id, + project_name, + folder_id, + task_id, + addon_name, + addon_version, + ) + self._start_animation(index) + self._start_animation(index) + + def _show_menu_on_group(self, action_id): action_items = self._model.get_group_items(action_id) menu = QtWidgets.QMenu(self) @@ -467,20 +480,9 @@ class ActionsWidget(QtWidgets.QWidget): result = menu.exec_(QtGui.QCursor.pos()) if not result: - return + return None - action_item = actions_mapping[result] - - self._controller.trigger_action( - action_item.action_type, - action_item.identifier, - project_name, - folder_id, - task_id, - action_item.addon_name, - action_item.addon_version, - ) - self._start_animation(index) + return actions_mapping[result] def _on_context_menu(self, point): """Creates menu to force skip opening last workfile.""" From f67002961a0c8093dcfdef0169df1a30b596d132 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 22 Apr 2025 15:26:46 +0200 Subject: [PATCH 020/103] added option to run detached process --- client/ayon_core/lib/__init__.py | 2 + client/ayon_core/lib/execute.py | 87 +++++++++++++++++++++++--------- 2 files changed, 64 insertions(+), 25 deletions(-) diff --git a/client/ayon_core/lib/__init__.py b/client/ayon_core/lib/__init__.py index ad906774d3..fff221b8a7 100644 --- a/client/ayon_core/lib/__init__.py +++ b/client/ayon_core/lib/__init__.py @@ -62,6 +62,7 @@ from .execute import ( run_subprocess, run_detached_process, run_ayon_launcher_process, + run_detached_ayon_launcher_process, path_to_subprocess_arg, CREATE_NO_WINDOW ) @@ -162,6 +163,7 @@ __all__ = [ "run_subprocess", "run_detached_process", "run_ayon_launcher_process", + "run_detached_ayon_launcher_process", "path_to_subprocess_arg", "CREATE_NO_WINDOW", diff --git a/client/ayon_core/lib/execute.py b/client/ayon_core/lib/execute.py index 516ea958f5..d632c63049 100644 --- a/client/ayon_core/lib/execute.py +++ b/client/ayon_core/lib/execute.py @@ -201,29 +201,7 @@ def clean_envs_for_ayon_process(env=None): return env -def run_ayon_launcher_process(*args, add_sys_paths=False, **kwargs): - """Execute AYON process with passed arguments and wait. - - Wrapper for 'run_process' which prepends AYON executable arguments - before passed arguments and define environments if are not passed. - - Values from 'os.environ' are used for environments if are not passed. - They are cleaned using 'clean_envs_for_ayon_process' function. - - Example: - ``` - run_ayon_process("run", "") - ``` - - Args: - *args (str): ayon-launcher cli arguments. - **kwargs (Any): Keyword arguments for subprocess.Popen. - - Returns: - str: Full output of subprocess concatenated stdout and stderr. - - """ - args = get_ayon_launcher_args(*args) +def _prepare_ayon_launcher_env(add_sys_paths: bool, kwargs): env = kwargs.pop("env", None) # Keep env untouched if are passed and not empty if not env: @@ -239,8 +217,7 @@ def run_ayon_launcher_process(*args, add_sys_paths=False, **kwargs): new_pythonpath.append(path) lookup_set.add(path) env["PYTHONPATH"] = os.pathsep.join(new_pythonpath) - - return run_subprocess(args, env=env, **kwargs) + return env def run_detached_process(args, **kwargs): @@ -314,6 +291,66 @@ def run_detached_process(args, **kwargs): return process +def run_ayon_launcher_process( + *args, add_sys_paths=False, **kwargs +): + """Execute AYON process with passed arguments and wait. + + Wrapper for 'run_process' which prepends AYON executable arguments + before passed arguments and define environments if are not passed. + + Values from 'os.environ' are used for environments if are not passed. + They are cleaned using 'clean_envs_for_ayon_process' function. + + Example: + ``` + run_ayon_process("run", "") + ``` + + Args: + *args (str): ayon-launcher cli arguments. + add_sys_paths (bool): Add system paths to PYTHONPATH. + **kwargs (Any): Keyword arguments for subprocess.Popen. + + Returns: + str: Full output of subprocess concatenated stdout and stderr. + + """ + args = get_ayon_launcher_args(*args) + env = _prepare_ayon_launcher_env(add_sys_paths, kwargs) + return run_subprocess(args, env=env, **kwargs) + + +def run_detached_ayon_launcher_process( + *args, add_sys_paths=False, **kwargs +): + """Execute AYON process with passed arguments and wait. + + Wrapper for 'run_process' which prepends AYON executable arguments + before passed arguments and define environments if are not passed. + + Values from 'os.environ' are used for environments if are not passed. + They are cleaned using 'clean_envs_for_ayon_process' function. + + Example: + ``` + run_ayon_process("run", "") + ``` + + Args: + *args (str): ayon-launcher cli arguments. + add_sys_paths (bool): Add system paths to PYTHONPATH. + **kwargs (Any): Keyword arguments for subprocess.Popen. + + Returns: + str: Full output of subprocess concatenated stdout and stderr. + + """ + args = get_ayon_launcher_args(*args) + env = _prepare_ayon_launcher_env(add_sys_paths, kwargs) + return run_detached_process(args, env=env, **kwargs) + + def path_to_subprocess_arg(path): """Prepare path for subprocess arguments. From d84f33b174772deaf132eca757ee14f2357c969f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 22 Apr 2025 15:28:58 +0200 Subject: [PATCH 021/103] use run detached ayon launcher process to run webactions --- .../ayon_core/tools/launcher/models/actions.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/client/ayon_core/tools/launcher/models/actions.py b/client/ayon_core/tools/launcher/models/actions.py index e36143f9df..7e7efd8ed9 100644 --- a/client/ayon_core/tools/launcher/models/actions.py +++ b/client/ayon_core/tools/launcher/models/actions.py @@ -1,7 +1,5 @@ import os import copy -import platform -import subprocess from urllib.parse import urlencode import ayon_api @@ -12,6 +10,7 @@ from ayon_core.lib import ( NestedCacheItem, CacheItem, get_settings_variant, + run_detached_ayon_launcher_process, ) from ayon_core.addon import AddonsManager from ayon_core.pipeline.actions import ( @@ -524,16 +523,11 @@ class ActionsModel: return if response_type == "launcher": - uri = data["uri"] - # There might be a better way to do this? - # Not sure if all linux distributions have 'xdg-open' available - platform_name = platform.system().lower() - if platform_name == "windows": - os.startfile(uri) - elif platform_name == "darwin": - subprocess.run(["open", uri]) - else: - subprocess.run(["xdg-open", uri]) + # Run AYON launcher process with uri in arguments + # NOTE This does pass environment variables of current process + # to the subprocess. + # NOTE We could 'take action' directly and use the arguments here + run_detached_ayon_launcher_process(data["uri"]) return raise Exception( From 9e21c4d01fee1cd2b40245b0290733a9a7b9f33c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 22 Apr 2025 16:59:28 +0200 Subject: [PATCH 022/103] handle missing 'color' --- client/ayon_core/tools/launcher/ui/actions_widget.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 69b5f76c29..d5d4a69cc5 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -142,7 +142,13 @@ class ActionsQtModel(QtGui.QStandardItemModel): icon_def = action_item.icon if not icon_def: icon_def = {"type": "transparent", "size": 256} - icon = get_qt_icon(icon_def) + icon_def = transparent_icon.copy() + elif icon_def.get("type") == "material-symbols": + if "name" not in icon_def: + icon_def = transparent_icon.copy() + elif not icon_def.get("color"): + icon_def["color"] = "#f5f5f5" + if is_group: label = action_item.label else: From 31803b0104fbb868d1cc31902f09bc35a99129ec Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 22 Apr 2025 16:59:44 +0200 Subject: [PATCH 023/103] don't fail whole UI if icon cannot be parsed --- .../ayon_core/tools/launcher/ui/actions_widget.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index d5d4a69cc5..fe946c6079 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -5,6 +5,7 @@ import collections from qtpy import QtWidgets, QtCore, QtGui from ayon_core import style +from ayon_core.lib import Logger from ayon_core.lib.attribute_definitions import ( UILabelDef, EnumDef, @@ -56,7 +57,8 @@ class ActionsQtModel(QtGui.QStandardItemModel): refreshed = QtCore.Signal() def __init__(self, controller): - super(ActionsQtModel, self).__init__() + self._log = Logger.get_logger(self.__class__.__name__) + super().__init__() controller.register_event_callback( "selection.project.changed", @@ -134,6 +136,7 @@ class ActionsQtModel(QtGui.QStandardItemModel): all_action_items_info.append((first_item, len(action_items) > 1)) groups_by_id[first_item.identifier] = action_items + transparent_icon = {"type": "transparent", "size": 256} new_items = [] items_by_id = {} action_items_by_id = {} @@ -141,7 +144,6 @@ class ActionsQtModel(QtGui.QStandardItemModel): action_item, is_group = action_item_info icon_def = action_item.icon if not icon_def: - icon_def = {"type": "transparent", "size": 256} icon_def = transparent_icon.copy() elif icon_def.get("type") == "material-symbols": if "name" not in icon_def: @@ -149,6 +151,15 @@ class ActionsQtModel(QtGui.QStandardItemModel): elif not icon_def.get("color"): icon_def["color"] = "#f5f5f5" + try: + icon = get_qt_icon(icon_def) + except Exception: + self._log.warning( + "Failed to parse icon definition", exc_info=True + ) + # Use empty icon if failed to parse definition + icon = get_qt_icon(transparent_icon.copy()) + if is_group: label = action_item.label else: From f2390a7c0602818ecb2c83813382125cc6ca2f07 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 22 Apr 2025 17:00:08 +0200 Subject: [PATCH 024/103] use correct color value --- client/ayon_core/tools/launcher/ui/actions_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index fe946c6079..549f54fc52 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -149,7 +149,7 @@ class ActionsQtModel(QtGui.QStandardItemModel): if "name" not in icon_def: icon_def = transparent_icon.copy() elif not icon_def.get("color"): - icon_def["color"] = "#f5f5f5" + icon_def["color"] = "#f4f5f5" try: icon = get_qt_icon(icon_def) From 6440974d5d511b4acafbe4a4541725f9a9784054 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 22 Apr 2025 17:05:26 +0200 Subject: [PATCH 025/103] raise error when running server action --- client/ayon_core/tools/launcher/models/actions.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/launcher/models/actions.py b/client/ayon_core/tools/launcher/models/actions.py index 7e7efd8ed9..70ba69d960 100644 --- a/client/ayon_core/tools/launcher/models/actions.py +++ b/client/ayon_core/tools/launcher/models/actions.py @@ -518,9 +518,10 @@ class ActionsModel: def _handle_webaction_response(self, data): response_type = data["type"] - # Nothing to do if response_type == "server": - return + raise Exception( + "Please use AYON web UI to run the action." + ) if response_type == "launcher": # Run AYON launcher process with uri in arguments From 4b385c141f59e034ce7e640de6367ad736fed2c9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 22 Apr 2025 17:11:31 +0200 Subject: [PATCH 026/103] change place where missing key is handled --- .../tools/launcher/ui/actions_widget.py | 5 ----- client/ayon_core/tools/utils/constants.py | 1 + client/ayon_core/tools/utils/lib.py | 21 ++++++++++++++----- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 549f54fc52..396859f3bf 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -145,11 +145,6 @@ class ActionsQtModel(QtGui.QStandardItemModel): icon_def = action_item.icon if not icon_def: icon_def = transparent_icon.copy() - elif icon_def.get("type") == "material-symbols": - if "name" not in icon_def: - icon_def = transparent_icon.copy() - elif not icon_def.get("color"): - icon_def["color"] = "#f4f5f5" try: icon = get_qt_icon(icon_def) diff --git a/client/ayon_core/tools/utils/constants.py b/client/ayon_core/tools/utils/constants.py index 0c92e3ccc8..b590d1d778 100644 --- a/client/ayon_core/tools/utils/constants.py +++ b/client/ayon_core/tools/utils/constants.py @@ -14,3 +14,4 @@ except AttributeError: DEFAULT_PROJECT_LABEL = "< Default >" PROJECT_NAME_ROLE = QtCore.Qt.UserRole + 101 PROJECT_IS_ACTIVE_ROLE = QtCore.Qt.UserRole + 102 +DEFAULT_WEB_ICON_COLOR = "#f4f5f5" diff --git a/client/ayon_core/tools/utils/lib.py b/client/ayon_core/tools/utils/lib.py index 4814e625df..4a1325dc91 100644 --- a/client/ayon_core/tools/utils/lib.py +++ b/client/ayon_core/tools/utils/lib.py @@ -19,7 +19,12 @@ from ayon_core.style import ( from ayon_core.resources import get_image_path from ayon_core.lib import Logger -from .constants import CHECKED_INT, UNCHECKED_INT, PARTIALLY_CHECKED_INT +from .constants import ( + CHECKED_INT, + UNCHECKED_INT, + PARTIALLY_CHECKED_INT, + DEFAULT_WEB_ICON_COLOR, +) log = Logger.get_logger(__name__) @@ -482,8 +487,14 @@ class _IconsCache: if icon_type == "path": parts = [icon_type, icon_def["path"]] - elif icon_type in {"awesome-font", "material-symbols"}: - color = icon_def["color"] or "" + elif icon_type == "awesome-font": + color = icon_def.get("color") or "" + if isinstance(color, QtGui.QColor): + color = color.name() + parts = [icon_type, icon_def["name"] or "", color] + + elif icon_type == "material-symbols": + color = icon_def.get("color") or DEFAULT_WEB_ICON_COLOR if isinstance(color, QtGui.QColor): color = color.name() parts = [icon_type, icon_def["name"] or "", color] @@ -517,7 +528,7 @@ class _IconsCache: elif icon_type == "awesome-font": icon_name = icon_def["name"] - icon_color = icon_def["color"] + icon_color = icon_def.get("color") icon = cls.get_qta_icon_by_name_and_color(icon_name, icon_color) if icon is None: icon = cls.get_qta_icon_by_name_and_color( @@ -525,7 +536,7 @@ class _IconsCache: elif icon_type == "material-symbols": icon_name = icon_def["name"] - icon_color = icon_def["color"] + icon_color = icon_def.get("color") or DEFAULT_WEB_ICON_COLOR if qtmaterialsymbols.get_icon_name_char(icon_name) is not None: icon = qtmaterialsymbols.get_icon(icon_name, icon_color) From 33df9ec9f57e92a299a5f88f5a9c754bc97eebda Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 22 Apr 2025 17:19:54 +0200 Subject: [PATCH 027/103] fox example --- client/ayon_core/lib/execute.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/lib/execute.py b/client/ayon_core/lib/execute.py index d632c63049..27af3d44ca 100644 --- a/client/ayon_core/lib/execute.py +++ b/client/ayon_core/lib/execute.py @@ -304,7 +304,7 @@ def run_ayon_launcher_process( Example: ``` - run_ayon_process("run", "") + run_ayon_launcher_process("run", "") ``` Args: @@ -334,7 +334,7 @@ def run_detached_ayon_launcher_process( Example: ``` - run_ayon_process("run", "") + run_detached_ayon_launcher_process("run", "") ``` Args: From 34d66c65bc55bc1c421eb1033b75a7e5c390cbd2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 7 May 2025 14:04:35 +0200 Subject: [PATCH 028/103] split trigger or action and webaction --- client/ayon_core/tools/launcher/abstract.py | 35 ++++++++++---- client/ayon_core/tools/launcher/control.py | 26 +++++++--- .../tools/launcher/models/actions.py | 48 ++++++++++++------- .../tools/launcher/ui/actions_widget.py | 17 +++---- client/ayon_core/tools/launcher/ui/window.py | 7 +++ 5 files changed, 93 insertions(+), 40 deletions(-) diff --git a/client/ayon_core/tools/launcher/abstract.py b/client/ayon_core/tools/launcher/abstract.py index c62faa28db..004d03bccb 100644 --- a/client/ayon_core/tools/launcher/abstract.py +++ b/client/ayon_core/tools/launcher/abstract.py @@ -297,26 +297,45 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): @abstractmethod def trigger_action( self, - action_label, - action_type, action_id, project_name, folder_id, task_id, - addon_name, - addon_version, ): """Trigger action on given context. Args: - action_label (str): Action label. - action_type (Literal["webaction", "local"]): Action type. action_id (str): Action identifier. project_name (Union[str, None]): Project name. folder_id (Union[str, None]): Folder id. task_id (Union[str, None]): Task id. - addon_name (Union[str, None]): Addon name. - addon_version (Union[str, None]): Addon version. + + """ + pass + + @abstractmethod + def trigger_webaction( + self, + identifier, + project_name, + folder_id, + task_id, + action_label, + addon_name, + addon_version, + form_data=None, + ): + """Trigger action on given context. + + Args: + identifier (str): Action identifier. + project_name (Union[str, None]): Project name. + folder_id (Union[str, None]): Folder id. + task_id (Union[str, None]): Task id. + action_label (str): Action label. + addon_name (str): Addon name. + addon_version (str): Addon version. + form_data (Optional[dict[str, Any]]): Form values of action. """ pass diff --git a/client/ayon_core/tools/launcher/control.py b/client/ayon_core/tools/launcher/control.py index cdd8806421..a69288dda1 100644 --- a/client/ayon_core/tools/launcher/control.py +++ b/client/ayon_core/tools/launcher/control.py @@ -137,24 +137,38 @@ class BaseLauncherController( def trigger_action( self, - action_label, - action_type, identifier, project_name, folder_id, task_id, - addon_name, - addon_version, ): self._actions_model.trigger_action( - action_label, - action_type, identifier, project_name, folder_id, task_id, + ) + + def trigger_webaction( + self, + identifier, + project_name, + folder_id, + task_id, + action_label, + addon_name, + addon_version, + form_data=None, + ): + self._actions_model.trigger_webaction( + identifier, + project_name, + folder_id, + task_id, + action_label, addon_name, addon_version, + form_data, ) def get_action_config_values( diff --git a/client/ayon_core/tools/launcher/models/actions.py b/client/ayon_core/tools/launcher/models/actions.py index 70ba69d960..d2b8e432f7 100644 --- a/client/ayon_core/tools/launcher/models/actions.py +++ b/client/ayon_core/tools/launcher/models/actions.py @@ -231,27 +231,11 @@ class ActionsModel: def trigger_action( self, - action_label, - acton_type, identifier, project_name, folder_id, task_id, - addon_name, - addon_version, ): - if acton_type == "webaction": - self._trigger_webaction( - action_label, - identifier, - project_name, - folder_id, - task_id, - addon_name, - addon_version, - ) - return - selection = self._prepare_selection(project_name, folder_id, task_id) failed = False error_message = None @@ -285,6 +269,38 @@ class ActionsModel: } ) + def trigger_webaction( + self, + identifier, + project_name, + folder_id, + task_id, + action_label, + addon_name, + addon_version, + form_data, + ): + entity_type = None + entity_ids = [] + if task_id: + entity_type = "task" + entity_ids.append(task_id) + elif folder_id: + entity_type = "folder" + entity_ids.append(folder_id) + + query = { + "addonName": addon_name, + "addonVersion": addon_version, + "identifier": identifier, + "variant": self._variant, + } + url = f"actions/execute?{urlencode(query)}" + context = { + "projectName": project_name, + "entityType": entity_type, + "entityIds": entity_ids, + } def get_action_config_values( self, identifier, diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 396859f3bf..65f6a810d1 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -466,16 +466,13 @@ class ActionsWidget(QtWidgets.QWidget): addon_name = index.data(ACTION_ADDON_NAME_ROLE) addon_version = index.data(ACTION_ADDON_VERSION_ROLE) - self._controller.trigger_action( - action_label, - action_type, - action_id, - project_name, - folder_id, - task_id, - addon_name, - addon_version, - ) + args = [action_id, project_name, folder_id, task_id] + if action_type == "webaction": + args.extend([action_label, addon_name, addon_version]) + self._controller.trigger_webaction(*args) + else: + self._controller.trigger_action(*args) + self._start_animation(index) self._start_animation(index) diff --git a/client/ayon_core/tools/launcher/ui/window.py b/client/ayon_core/tools/launcher/ui/window.py index aa336108ed..b814ed9467 100644 --- a/client/ayon_core/tools/launcher/ui/window.py +++ b/client/ayon_core/tools/launcher/ui/window.py @@ -128,6 +128,10 @@ class LauncherWindow(QtWidgets.QWidget): "action.trigger.finished", self._on_action_trigger_finished, ) + controller.register_event_callback( + "webaction.trigger.started", + self._on_webaction_trigger_started, + ) self._controller = controller @@ -223,6 +227,9 @@ class LauncherWindow(QtWidgets.QWidget): return self._echo("Failed: {}".format(event["error_message"])) + def _on_webaction_trigger_started(self, event): + self._echo("Running webaction: {}".format(event["full_label"])) + def _is_page_slide_anim_running(self): return ( self._page_slide_anim.state() == QtCore.QAbstractAnimation.Running From 83386806c7d98c13dc004051bba07c56ed7d2f0e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 7 May 2025 14:05:18 +0200 Subject: [PATCH 029/103] basic implementation of webactions handling --- .../tools/launcher/models/actions.py | 252 ++++++++++-------- .../tools/launcher/ui/actions_widget.py | 20 ++ client/ayon_core/tools/launcher/ui/window.py | 20 ++ 3 files changed, 178 insertions(+), 114 deletions(-) diff --git a/client/ayon_core/tools/launcher/models/actions.py b/client/ayon_core/tools/launcher/models/actions.py index d2b8e432f7..0d23dc53d5 100644 --- a/client/ayon_core/tools/launcher/models/actions.py +++ b/client/ayon_core/tools/launcher/models/actions.py @@ -1,6 +1,9 @@ import os import copy +import webbrowser +from dataclasses import dataclass, asdict from urllib.parse import urlencode +from typing import Any, Optional import ayon_api @@ -20,38 +23,6 @@ from ayon_core.pipeline.actions import ( ) -# class Action: -# def __init__(self, label, icon=None, identifier=None): -# self._label = label -# self._icon = icon -# self._callbacks = [] -# self._identifier = identifier or uuid.uuid4().hex -# self._checked = True -# self._checkable = False -# -# def set_checked(self, checked): -# self._checked = checked -# -# def set_checkable(self, checkable): -# self._checkable = checkable -# -# def set_label(self, label): -# self._label = label -# -# def add_callback(self, callback): -# self._callbacks = callback -# -# -# class Menu: -# def __init__(self, label, icon=None): -# self.label = label -# self.icon = icon -# self._actions = [] -# -# def add_action(self, action): -# self._actions.append(action) - - class ActionItem: """Item representing single action to trigger. @@ -128,6 +99,38 @@ class ActionItem: return cls(**data) +@dataclass +class WebactionForm: + fields: list[dict[str, Any]] + title: str + submit_label: str + submit_icon: str + cancel_label: str + cancel_icon: str + + +@dataclass +class WebactionResponse: + response_type: str + success: bool + message: Optional[str] = None + clipboard_text: Optional[str] = None + form: Optional[WebactionForm] = None + error_message: Optional[str] = None + + def to_data(self): + return asdict(self) + + @classmethod + def from_data(cls, data): + data = data.copy() + form = data["form"] + if form: + data["form"] = WebactionForm(**form) + + return cls(**data) + + def get_action_icon(action): """Get action icon info. @@ -301,6 +304,53 @@ class ActionsModel: "entityType": entity_type, "entityIds": entity_ids, } + if form_data is not None: + context["formData"] = form_data + + try: + self._controller.emit_event( + "webaction.trigger.started", + { + "identifier": identifier, + "full_label": action_label, + } + ) + + conn = ayon_api.get_server_api_connection() + # Add 'referer' header to the request + # - ayon-api 1.1.1 adds the value to the header automatically + headers = conn.get_headers() + if "referer" in headers: + headers = None + else: + headers["referer"] = conn.get_base_url() + response = ayon_api.raw_post(url, headers=headers, json=context) + response.raise_for_status() + handle_response = self._handle_webaction_response(response.data) + + except Exception: + self.log.warning("Action trigger failed.", exc_info=True) + handle_response = WebactionResponse( + "unknown", + False, + error_message="Failed to trigger webaction.", + ) + + data = handle_response.to_data() + data.update({ + "identifier": identifier, + "action_label": action_label, + "project_name": project_name, + "folder_id": folder_id, + "task_id": task_id, + "addon_name": addon_name, + "addon_version": addon_version, + }) + self._controller.emit_event( + "webaction.trigger.finished", + data, + ) + def get_action_config_values( self, identifier, @@ -444,13 +494,18 @@ class ActionsModel: icon["type"] = "ayon_url" config_fields = action.get("configFields") or [] + variant_label = action["label"] + group_label = action.get("groupLabel") + if not group_label: + group_label = variant_label + variant_label = None action_items.append(ActionItem( "webaction", action["identifier"], + group_label, + variant_label, # action["category"], - action["label"], - None, icon, action["order"], action["addonName"], @@ -459,97 +514,66 @@ class ActionsModel: )) cache.update_data(action_items) - return cache.get_data() - def _trigger_webaction( - self, - action_label, - identifier, - project_name, - folder_id, - task_id, - addon_name, - addon_version, - ): - entity_type = None - entity_ids = [] - if task_id: - entity_type = "task" - entity_ids.append(task_id) - elif folder_id: - entity_type = "folder" - entity_ids.append(folder_id) - - query = { - "addonName": addon_name, - "addonVersion": addon_version, - "identifier": identifier, - "variant": self._variant, - } - url = f"actions/execute?{urlencode(query)}" - context = { - "projectName": project_name, - "entityType": entity_type, - "entityIds": entity_ids, - } - - failed = False - error_message = None - try: - self._controller.emit_event( - "action.trigger.started", - { - "identifier": identifier, - "full_label": action_label, - } - ) - - conn = ayon_api.get_server_api_connection() - headers = conn.get_headers() - headers["referer"] = conn.get_base_url() - response = ayon_api.raw_post(url, headers=headers, json=context) - response.raise_for_status() - data = response.data - if data["success"] is True: - self._handle_webaction_response(data) - else: - error_message = data["message"] - failed = True - - except Exception as exc: - self.log.warning("Action trigger failed.", exc_info=True) - failed = True - error_message = str(exc) - - self._controller.emit_event( - "action.trigger.finished", - { - "identifier": identifier, - "failed": failed, - "error_message": error_message, - "full_label": action_label, - } - ) - - def _handle_webaction_response(self, data): + def _handle_webaction_response(self, data) -> WebactionResponse: response_type = data["type"] + # Backwards compatibility -> 'server' type is not available since + # AYON backend 1.8.3 if response_type == "server": - raise Exception( - "Please use AYON web UI to run the action." + return WebactionResponse( + response_type, + False, + error_message="Please use AYON web UI to run the action.", ) - if response_type == "launcher": + payload = data.get("payload") or {} + + # TODO handle 'extra_download' + download_uri = payload.get("extra_download") + if download_uri is not None: + # TODO check if uri is relative or absolute + webbrowser.open_new_tab(download_uri) + + response = WebactionResponse( + response_type, + data["success"], + data.get("message"), + payload.get("extra_clipboard"), + ) + if response_type == "simple": + pass + + elif response_type == "redirect": + # NOTE unused 'newTab' key because we always have to + # open new tab from desktop app. + if not webbrowser.open_new_tab(payload["uri"]): + payload.error_message = "Failed to open web browser." + + elif response_type == "form": + response.form = payload["form"] + + elif response_type == "launcher": # Run AYON launcher process with uri in arguments # NOTE This does pass environment variables of current process # to the subprocess. # NOTE We could 'take action' directly and use the arguments here - run_detached_ayon_launcher_process(data["uri"]) - return + if payload is not None: + uri = payload["uri"] + else: + uri = data["uri"] + run_detached_ayon_launcher_process(uri) - raise Exception( - "Unknown webaction response type '{response_type}'" - ) + elif response_type in ("query", "navigate"): + response.error_message = ( + "Please use AYON web UI to run the action." + ) + + else: + self.log.warning(f"Unknown webaction response type '{response_type}'") + response.error_message = "Unknown webaction response type." + + return response def _get_discovered_action_classes(self): if self._discovered_actions is None: diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 65f6a810d1..c7d373bde8 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -402,6 +402,26 @@ class ActionsWidget(QtWidgets.QWidget): def refresh(self): self._model.refresh() + def handle_webaction_form_event(self, event): + # NOTE The 'ActionsWidget' should be responsible for handling this + # but because we're showing messages to user it is handled by window + identifier = event["identifier"] + dialog = self._create_config_dialog(event["form"]) + result = dialog.exec_() + if result == QtWidgets.QDialog.Rejected: + return + form_data = dialog.get_values() + self._controller.trigger_webaction( + identifier, + event["project_name"], + event["folder_id"], + event["task_id"], + event["action_label"], + event["addon_name"], + event["addon_version"], + form_data, + ) + def _set_row_height(self, rows): self.setMinimumHeight(rows * 75) diff --git a/client/ayon_core/tools/launcher/ui/window.py b/client/ayon_core/tools/launcher/ui/window.py index b814ed9467..2b27bfbd50 100644 --- a/client/ayon_core/tools/launcher/ui/window.py +++ b/client/ayon_core/tools/launcher/ui/window.py @@ -132,6 +132,10 @@ class LauncherWindow(QtWidgets.QWidget): "webaction.trigger.started", self._on_webaction_trigger_started, ) + controller.register_event_callback( + "webaction.trigger.finished", + self._on_webaction_trigger_finished, + ) self._controller = controller @@ -230,6 +234,22 @@ class LauncherWindow(QtWidgets.QWidget): def _on_webaction_trigger_started(self, event): self._echo("Running webaction: {}".format(event["full_label"])) + def _on_webaction_trigger_finished(self, event): + clipboard_text = event["clipboard_text"] + if clipboard_text: + clipboard = QtWidgets.QApplication.clipboard() + clipboard.setText(filled_source) + + # TODO use toast messages + if event["message"]: + self._echo(event["message"]) + + if event["error_message"]: + self._echo(event["message"]) + + if event["form"]: + self._actions_widget.handle_webaction_form_event(event) + def _is_page_slide_anim_running(self): return ( self._page_slide_anim.state() == QtCore.QAbstractAnimation.Running From c7526b590f74687ebc877d5c47247def04900714 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 7 May 2025 14:07:40 +0200 Subject: [PATCH 030/103] revert dialog conditions --- client/ayon_core/tools/launcher/ui/actions_widget.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index c7d373bde8..5fb6441ffb 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -408,7 +408,7 @@ class ActionsWidget(QtWidgets.QWidget): identifier = event["identifier"] dialog = self._create_config_dialog(event["form"]) result = dialog.exec_() - if result == QtWidgets.QDialog.Rejected: + if result != QtWidgets.QDialog.Accepted : return form_data = dialog.get_values() self._controller.trigger_webaction( @@ -539,7 +539,7 @@ class ActionsWidget(QtWidgets.QWidget): dialog = self._create_config_dialog(config_fields) dialog.set_values(values) result = dialog.exec_() - if result == QtWidgets.QDialog.Rejected: + if result != QtWidgets.QDialog.Accepted: return new_values = dialog.get_values() self._controller.set_action_config_values( From acd591691b4fddaaab6104d6b17cc8c4e4e65f20 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 7 May 2025 14:15:56 +0200 Subject: [PATCH 031/103] better download uri handling --- client/ayon_core/tools/launcher/models/actions.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/tools/launcher/models/actions.py b/client/ayon_core/tools/launcher/models/actions.py index 0d23dc53d5..77299cf8b2 100644 --- a/client/ayon_core/tools/launcher/models/actions.py +++ b/client/ayon_core/tools/launcher/models/actions.py @@ -1,9 +1,9 @@ import os import copy -import webbrowser from dataclasses import dataclass, asdict -from urllib.parse import urlencode +from urllib.parse import urlencode, urlparse from typing import Any, Optional +import webbrowser import ayon_api @@ -529,10 +529,15 @@ class ActionsModel: payload = data.get("payload") or {} - # TODO handle 'extra_download' download_uri = payload.get("extra_download") if download_uri is not None: - # TODO check if uri is relative or absolute + # Find out if is relative or absolute URL + if not urlparse(download_uri).scheme: + ayon_url = ayon_api.get_base_url().rstrip("/") + path = download_uri.lstrip("/") + download_uri = f"{ayon_url}/{path}" + + # Use webbrowser to open file webbrowser.open_new_tab(download_uri) response = WebactionResponse( From 4728258a76a4dee83d779ebdc1bab4411a1f2b3f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 7 May 2025 16:37:20 +0200 Subject: [PATCH 032/103] download image from url --- client/ayon_core/tools/utils/lib.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/utils/lib.py b/client/ayon_core/tools/utils/lib.py index 4a1325dc91..6985254382 100644 --- a/client/ayon_core/tools/utils/lib.py +++ b/client/ayon_core/tools/utils/lib.py @@ -4,6 +4,7 @@ import io import contextlib import collections import traceback +import urllib.request from functools import partial from typing import Union, Any @@ -542,10 +543,18 @@ class _IconsCache: elif icon_type == "url": url = icon_def["url"] - icon = QtGui.QPixmap(url) + try: + content = urllib.request.urlopen(url).read() + pix = QtGui.QPixmap() + pix.loadFromData(content) + icon = QtGui.QIcon(pix) + except Exception as exc: + log.warning(f"Failed to download image '{url}'") + icon = None elif icon_type == "ayon_url": - url = ayon_api.get_base_url() + icon_def["url"] + url = icon_def["url"].lstrip("/") + url = f"{ayon_api.get_base_url()}/{url}" stream = io.BytesIO() ayon_api.download_file_to_stream(url, stream) pix = QtGui.QPixmap() From 34dcc7840b70551e83a9433169a2912e6dbc5a10 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 7 May 2025 16:37:39 +0200 Subject: [PATCH 033/103] added more options to attribute definitions dialog --- .../ayon_core/tools/attribute_defs/dialog.py | 68 +++++++++++++++++-- 1 file changed, 62 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/tools/attribute_defs/dialog.py b/client/ayon_core/tools/attribute_defs/dialog.py index 53405105f5..7423d58475 100644 --- a/client/ayon_core/tools/attribute_defs/dialog.py +++ b/client/ayon_core/tools/attribute_defs/dialog.py @@ -1,22 +1,58 @@ -from qtpy import QtWidgets +from __future__ import annotations + +from typing import Optional + +from qtpy import QtWidgets, QtGui + +from ayon_core.style import load_stylesheet +from ayon_core.resources import get_ayon_icon_filepath +from ayon_core.lib import AbstractAttrDef from .widgets import AttributeDefinitionsWidget class AttributeDefinitionsDialog(QtWidgets.QDialog): - def __init__(self, attr_defs, parent=None): + def __init__( + self, + attr_defs: list[AbstractAttrDef], + title: Optional[str] = None, + submit_label: Optional[str] = None, + cancel_label: Optional[str] = None, + submit_icon: Optional[QtGui.QIcon] = None, + cancel_icon: Optional[QtGui.QIcon] = None, + parent: Optional[QtWidgets.QWidget] = None, + ): super().__init__(parent) + if title: + self.setWindowTitle(title) + + icon = QtGui.QIcon(get_ayon_icon_filepath()) + self.setWindowIcon(icon) + self.setStyleSheet(load_stylesheet()) + attrs_widget = AttributeDefinitionsWidget(attr_defs, self) + if submit_label is None: + submit_label = "OK" + + if cancel_label is None: + cancel_label = "Cancel" + btns_widget = QtWidgets.QWidget(self) - ok_btn = QtWidgets.QPushButton("OK", btns_widget) - cancel_btn = QtWidgets.QPushButton("Cancel", btns_widget) + cancel_btn = QtWidgets.QPushButton(cancel_label, btns_widget) + submit_btn = QtWidgets.QPushButton(submit_label, btns_widget) + + if submit_icon is not None: + submit_btn.setIcon(submit_icon) + + if cancel_icon is not None: + cancel_btn.setIcon(cancel_icon) btns_layout = QtWidgets.QHBoxLayout(btns_widget) btns_layout.setContentsMargins(0, 0, 0, 0) btns_layout.addStretch(1) - btns_layout.addWidget(ok_btn, 0) + btns_layout.addWidget(submit_btn, 0) btns_layout.addWidget(cancel_btn, 0) main_layout = QtWidgets.QVBoxLayout(self) @@ -24,13 +60,33 @@ class AttributeDefinitionsDialog(QtWidgets.QDialog): main_layout.addStretch(1) main_layout.addWidget(btns_widget, 0) - ok_btn.clicked.connect(self.accept) + submit_btn.clicked.connect(self.accept) cancel_btn.clicked.connect(self.reject) self._attrs_widget = attrs_widget + self._submit_btn = submit_btn + self._cancel_btn = cancel_btn def get_values(self): return self._attrs_widget.current_value() def set_values(self, values): self._attrs_widget.set_value(values) + + def set_submit_label(self, text: str): + self._submit_btn.setText(text) + + def set_submit_icon(self, icon: QtGui.QIcon): + self._submit_btn.setIcon(icon) + + def set_submit_visible(self, visible: bool): + self._submit_btn.setVisible(visible) + + def set_cancel_label(self, text: str): + self._cancel_btn.setText(text) + + def set_cancel_icon(self, icon: QtGui.QIcon): + self._cancel_btn.setIcon(icon) + + def set_cancel_visible(self, visible: bool): + self._cancel_btn.setVisible(visible) From 149c9049a2d9ceb6b7409c935e2d9be0eb637951 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 7 May 2025 17:07:34 +0200 Subject: [PATCH 034/103] added markdown label --- client/ayon_core/tools/utils/__init__.py | 2 ++ client/ayon_core/tools/utils/widgets.py | 28 ++++++++++++++++++++++++ client/pyproject.toml | 1 + 3 files changed, 31 insertions(+) diff --git a/client/ayon_core/tools/utils/__init__.py b/client/ayon_core/tools/utils/__init__.py index 9206af9beb..8688430c71 100644 --- a/client/ayon_core/tools/utils/__init__.py +++ b/client/ayon_core/tools/utils/__init__.py @@ -6,6 +6,7 @@ from .widgets import ( CustomTextComboBox, PlaceholderLineEdit, PlaceholderPlainTextEdit, + MarkdownLabel, ElideLabel, HintedLineEdit, ExpandingTextEdit, @@ -91,6 +92,7 @@ __all__ = ( "CustomTextComboBox", "PlaceholderLineEdit", "PlaceholderPlainTextEdit", + "MarkdownLabel", "ElideLabel", "HintedLineEdit", "ExpandingTextEdit", diff --git a/client/ayon_core/tools/utils/widgets.py b/client/ayon_core/tools/utils/widgets.py index 0cd6d68ab3..a95f34006b 100644 --- a/client/ayon_core/tools/utils/widgets.py +++ b/client/ayon_core/tools/utils/widgets.py @@ -6,6 +6,11 @@ from qtpy import QtWidgets, QtCore, QtGui import qargparse import qtawesome +try: + import markdown +except (ImportError, SyntaxError): + markdown = None + from ayon_core.style import ( get_objected_colors, get_style_image_path, @@ -131,6 +136,29 @@ class PlaceholderPlainTextEdit(QtWidgets.QPlainTextEdit): viewport.setPalette(filter_palette) +class MarkdownLabel(QtWidgets.QLabel): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Enable word wrap by default + self.setWordWrap(True) + + self.setText(self.text()) + + def setText(self, text): + super().setText(self._md_to_html(text)) + + @staticmethod + def _md_to_html(text): + if markdown is None: + # This does add style definition to the markdown which does not + # feel natural in the UI (but still better than raw MD). + doc = QtGui.QTextDocument() + doc.setMarkdown(text) + return doc.toHtml() + return markdown.markdown(text) + + class ElideLabel(QtWidgets.QLabel): """Label which elide text. diff --git a/client/pyproject.toml b/client/pyproject.toml index edf7f57317..6416d9b8e1 100644 --- a/client/pyproject.toml +++ b/client/pyproject.toml @@ -4,6 +4,7 @@ description="AYON core addon." [tool.poetry.dependencies] python = ">=3.9.1,<3.10" +markdown = "^3.4.1" clique = "1.6.*" jsonschema = "^2.6.0" pyblish-base = "^1.8.11" From c29d14d0291e69e0b768bea198e73981cd42ff90 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 7 May 2025 17:07:51 +0200 Subject: [PATCH 035/103] use markdown label for attribute definitions label --- client/ayon_core/tools/attribute_defs/widgets.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/attribute_defs/widgets.py b/client/ayon_core/tools/attribute_defs/widgets.py index dbd65fd215..004a3bd921 100644 --- a/client/ayon_core/tools/attribute_defs/widgets.py +++ b/client/ayon_core/tools/attribute_defs/widgets.py @@ -22,6 +22,7 @@ from ayon_core.tools.utils import ( FocusSpinBox, FocusDoubleSpinBox, MultiSelectionComboBox, + MarkdownLabel, PlaceholderLineEdit, PlaceholderPlainTextEdit, set_style_property, @@ -350,7 +351,7 @@ class SeparatorAttrWidget(_BaseAttrDefWidget): class LabelAttrWidget(_BaseAttrDefWidget): def _ui_init(self): - input_widget = QtWidgets.QLabel(self) + input_widget = MarkdownLabel(self) label = self.attr_def.label if label: input_widget.setText(str(label)) From 27dc51a090b018fc073a692ec31de371a49635e6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 7 May 2025 17:08:08 +0200 Subject: [PATCH 036/103] enhance actions utils in loader --- client/ayon_core/tools/loader/ui/actions_utils.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/actions_utils.py b/client/ayon_core/tools/loader/ui/actions_utils.py index 5a988ef4c2..b601cd95bd 100644 --- a/client/ayon_core/tools/loader/ui/actions_utils.py +++ b/client/ayon_core/tools/loader/ui/actions_utils.py @@ -84,15 +84,17 @@ def _get_options(action, action_item, parent): if not getattr(action, "optioned", False) or not options: return {} + dialog_title = action.label + " Options" if isinstance(options[0], AbstractAttrDef): qargparse_options = False - dialog = AttributeDefinitionsDialog(options, parent) + dialog = AttributeDefinitionsDialog( + options, title=dialog_title, parent=parent + ) else: qargparse_options = True dialog = OptionDialog(parent) dialog.create(options) - - dialog.setWindowTitle(action.label + " Options") + dialog.setWindowTitle(dialog_title) if not dialog.exec_(): return None From 1a7b137f592cae963bd0096196c8bda9bfd057a9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 7 May 2025 17:08:28 +0200 Subject: [PATCH 037/103] better detection of ayon url --- client/ayon_core/tools/launcher/models/actions.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/launcher/models/actions.py b/client/ayon_core/tools/launcher/models/actions.py index 77299cf8b2..0ce51ad9ef 100644 --- a/client/ayon_core/tools/launcher/models/actions.py +++ b/client/ayon_core/tools/launcher/models/actions.py @@ -490,8 +490,9 @@ class ActionsModel: # NOTE Settings variant may be important for triggering? # - action["variant"] icon = action.get("icon") - if icon and icon["type"] == "url" and icon["url"].startswith("/"): - icon["type"] = "ayon_url" + if icon and icon["type"] == "url": + if not urlparse(icon["url"]).scheme: + icon["type"] = "ayon_url" config_fields = action.get("configFields") or [] variant_label = action["label"] From bb168a1186565fedfd93729777d04bd995987f09 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 7 May 2025 17:08:45 +0200 Subject: [PATCH 038/103] actually fill form correctly --- .../tools/launcher/models/actions.py | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/launcher/models/actions.py b/client/ayon_core/tools/launcher/models/actions.py index 0ce51ad9ef..d5a412e33a 100644 --- a/client/ayon_core/tools/launcher/models/actions.py +++ b/client/ayon_core/tools/launcher/models/actions.py @@ -557,7 +557,28 @@ class ActionsModel: payload.error_message = "Failed to open web browser." elif response_type == "form": - response.form = payload["form"] + submit_icon = payload["submit_icon"] or None + cancel_icon = payload["cancel_icon"] or None + if submit_icon: + submit_icon = { + "type": "material-symbols", + "name": submit_icon, + } + + if cancel_icon: + cancel_icon = { + "type": "material-symbols", + "name": cancel_icon, + } + + response.form = WebactionForm( + fields=payload["fields"], + title=payload["title"], + submit_label=payload["submit_label"], + cancel_label=payload["cancel_label"], + submit_icon=submit_icon, + cancel_icon=cancel_icon, + ) elif response_type == "launcher": # Run AYON launcher process with uri in arguments From 0fc29ae46fe2da9150ca934cb5e80e015b7613d8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 7 May 2025 17:09:00 +0200 Subject: [PATCH 039/103] show form correctly --- .../tools/launcher/ui/actions_widget.py | 74 ++++++++++++++++--- 1 file changed, 62 insertions(+), 12 deletions(-) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 5fb6441ffb..1b8fa2c495 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -4,7 +4,6 @@ import collections from qtpy import QtWidgets, QtCore, QtGui -from ayon_core import style from ayon_core.lib import Logger from ayon_core.lib.attribute_definitions import ( UILabelDef, @@ -406,9 +405,26 @@ class ActionsWidget(QtWidgets.QWidget): # NOTE The 'ActionsWidget' should be responsible for handling this # but because we're showing messages to user it is handled by window identifier = event["identifier"] - dialog = self._create_config_dialog(event["form"]) + form = event["form"] + submit_icon = form["submit_icon"] + if submit_icon: + submit_icon = get_qt_icon(submit_icon) + + cancel_icon = form["cancel_icon"] + if cancel_icon: + cancel_icon = get_qt_icon(cancel_icon) + + dialog = self._create_attrs_dialog( + form["fields"], + form["title"], + form["submit_label"], + form["cancel_label"], + submit_icon, + cancel_icon, + ) + dialog.setMinimumSize(380, 180) result = dialog.exec_() - if result != QtWidgets.QDialog.Accepted : + if result != QtWidgets.QDialog.Accepted: return form_data = dialog.get_values() self._controller.trigger_webaction( @@ -536,7 +552,12 @@ class ActionsWidget(QtWidgets.QWidget): addon_version=index.data(ACTION_ADDON_VERSION_ROLE), ) - dialog = self._create_config_dialog(config_fields) + dialog = self._create_attrs_dialog( + config_fields, + "Action Config", + "Save", + "Cancel", + ) dialog.set_values(values) result = dialog.exec_() if result != QtWidgets.QDialog.Accepted: @@ -552,8 +573,16 @@ class ActionsWidget(QtWidgets.QWidget): values=new_values, ) - def _create_config_dialog(self, config_fields): - """Creates config widget. + def _create_attrs_dialog( + self, + config_fields, + title, + submit_label, + cancel_label, + submit_icon=None, + cancel_icon=None, + ): + """Creates attribute definitions dialog. Types: label - 'text' @@ -572,8 +601,11 @@ class ActionsWidget(QtWidgets.QWidget): field_type = config_field["type"] attr_def = None if field_type == "label": + label = config_field.get("text") + if label is None: + label = config_field["value"] attr_def = UILabelDef( - config_field["text"], key=uuid.uuid4().hex + label, key=uuid.uuid4().hex ) elif field_type == "boolean": attr_def = BoolDef( @@ -604,10 +636,10 @@ class ActionsWidget(QtWidgets.QWidget): elif field_type in ("select", "multiselect"): attr_def = EnumDef( config_field["name"], + items=config_field["options"], default=config_field["value"], label=config_field["label"], - options=config_field["options"], - multi_select=field_type == "multiselect", + multiselection=field_type == "multiselect", ) elif field_type == "hidden": attr_def = HiddenDef( @@ -623,7 +655,25 @@ class ActionsWidget(QtWidgets.QWidget): ) attr_defs.append(attr_def) - dialog = AttributeDefinitionsDialog(attr_defs, parent=self) - dialog.setWindowTitle("Action Config") - dialog.setStyleSheet(style.load_stylesheet()) + dialog = AttributeDefinitionsDialog( + attr_defs, + title=title, + parent=self, + ) + if submit_label: + dialog.set_submit_label(submit_label) + else: + dialog.set_submit_visible(False) + + if submit_icon: + dialog.set_submit_icon(submit_icon) + + if cancel_label: + dialog.set_cancel_label(cancel_label) + else: + dialog.set_cancel_visible(False) + + if cancel_icon: + dialog.set_cancel_icon(cancel_icon) + return dialog From cd635385229e0a664354b9a289dbccac93c1c3bd Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 7 May 2025 17:12:59 +0200 Subject: [PATCH 040/103] fix variable name --- client/ayon_core/tools/launcher/ui/window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/launcher/ui/window.py b/client/ayon_core/tools/launcher/ui/window.py index 2b27bfbd50..3f3e4bb1de 100644 --- a/client/ayon_core/tools/launcher/ui/window.py +++ b/client/ayon_core/tools/launcher/ui/window.py @@ -238,7 +238,7 @@ class LauncherWindow(QtWidgets.QWidget): clipboard_text = event["clipboard_text"] if clipboard_text: clipboard = QtWidgets.QApplication.clipboard() - clipboard.setText(filled_source) + clipboard.setText(clipboard_text) # TODO use toast messages if event["message"]: From d7d3d1b376b723483e78aa1ae5bedb0d24487176 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 7 May 2025 17:13:53 +0200 Subject: [PATCH 041/103] log traceback --- client/ayon_core/tools/utils/lib.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/utils/lib.py b/client/ayon_core/tools/utils/lib.py index 6985254382..9ee89fbf3a 100644 --- a/client/ayon_core/tools/utils/lib.py +++ b/client/ayon_core/tools/utils/lib.py @@ -548,8 +548,10 @@ class _IconsCache: pix = QtGui.QPixmap() pix.loadFromData(content) icon = QtGui.QIcon(pix) - except Exception as exc: - log.warning(f"Failed to download image '{url}'") + except Exception: + log.warning( + f"Failed to download image '{url}'", exc_info=True + ) icon = None elif icon_type == "ayon_url": From 6bcf514ba284bedcac25d5bd8ef9aa1c43fc5e14 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 7 May 2025 17:22:21 +0200 Subject: [PATCH 042/103] add TypeError to avoid typehint issues --- client/ayon_core/tools/utils/widgets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/utils/widgets.py b/client/ayon_core/tools/utils/widgets.py index a95f34006b..a19b0a8966 100644 --- a/client/ayon_core/tools/utils/widgets.py +++ b/client/ayon_core/tools/utils/widgets.py @@ -8,7 +8,7 @@ import qtawesome try: import markdown -except (ImportError, SyntaxError): +except (ImportError, SyntaxError, TypeError): markdown = None from ayon_core.style import ( From 839eecc5fd2eba723a9b736d485f17c5b69520a3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 7 May 2025 17:41:37 +0200 Subject: [PATCH 043/103] remove unused variable --- client/ayon_core/tools/attribute_defs/widgets.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/client/ayon_core/tools/attribute_defs/widgets.py b/client/ayon_core/tools/attribute_defs/widgets.py index 004a3bd921..1e948b2d28 100644 --- a/client/ayon_core/tools/attribute_defs/widgets.py +++ b/client/ayon_core/tools/attribute_defs/widgets.py @@ -248,12 +248,10 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget): def set_value(self, value): new_value = copy.deepcopy(value) - unused_keys = set(new_value.keys()) for widget in self._widgets_by_id.values(): attr_def = widget.attr_def if attr_def.key not in new_value: continue - unused_keys.remove(attr_def.key) widget_value = new_value[attr_def.key] if widget_value is None: From 0cbdda1a5ee394508e851574e195f0d2320d0e2f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 7 May 2025 17:41:51 +0200 Subject: [PATCH 044/103] store the values into variables --- .../tools/launcher/ui/actions_widget.py | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 1b8fa2c495..2de8808101 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -543,13 +543,18 @@ class ActionsWidget(QtWidgets.QWidget): if not config_fields: return + project_name = self._model.get_selected_project_name() + folder_id = self._model.get_selected_folder_id() + task_id = self._model.get_selected_task_id() + addon_name = index.data(ACTION_ADDON_NAME_ROLE) + addon_version = index.data(ACTION_ADDON_VERSION_ROLE) values = self._controller.get_action_config_values( action_id, - project_name=self._model.get_selected_project_name(), - folder_id=self._model.get_selected_folder_id(), - task_id=self._model.get_selected_task_id(), - addon_name=index.data(ACTION_ADDON_NAME_ROLE), - addon_version=index.data(ACTION_ADDON_VERSION_ROLE), + project_name=project_name, + folder_id=folder_id, + task_id=task_id, + addon_name=addon_name, + addon_version=addon_version, ) dialog = self._create_attrs_dialog( @@ -565,11 +570,11 @@ class ActionsWidget(QtWidgets.QWidget): new_values = dialog.get_values() self._controller.set_action_config_values( action_id, - project_name=self._model.get_selected_project_name(), - folder_id=self._model.get_selected_folder_id(), - task_id=self._model.get_selected_task_id(), - addon_name=index.data(ACTION_ADDON_NAME_ROLE), - addon_version=index.data(ACTION_ADDON_VERSION_ROLE), + project_name=project_name, + folder_id=folder_id, + task_id=task_id, + addon_name=addon_name, + addon_version=addon_version, values=new_values, ) From d5063e0042aa9118a665a84282eb72c78645518e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 7 May 2025 17:59:21 +0200 Subject: [PATCH 045/103] convert default values to correct type --- .../ayon_core/tools/launcher/ui/actions_widget.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 2de8808101..36d1a6a7e1 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -613,9 +613,13 @@ class ActionsWidget(QtWidgets.QWidget): label, key=uuid.uuid4().hex ) elif field_type == "boolean": + value = config_field["value"] + if isinstance(value, str): + value = value.lower() == "true" + attr_def = BoolDef( config_field["name"], - default=config_field["value"], + default=value, label=config_field["label"], ) elif field_type == "text": @@ -629,9 +633,14 @@ class ActionsWidget(QtWidgets.QWidget): # syntax=config_field["syntax"], ) elif field_type in ("integer", "float"): + value = config_field["value"] + if field_type == "integer": + value = int(value) + else: + value = float(value) attr_def = NumberDef( config_field["name"], - default=config_field["value"], + default=value, label=config_field["label"], decimals=0 if field_type == "integer" else 5, placeholder=config_field["placeholder"], From 131b4fb61916c5ac6595a896eda975f72d67aedd Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 9 May 2025 10:32:04 +0200 Subject: [PATCH 046/103] safe key access --- .../tools/launcher/ui/actions_widget.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 36d1a6a7e1..8973b34f07 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -620,16 +620,16 @@ class ActionsWidget(QtWidgets.QWidget): attr_def = BoolDef( config_field["name"], default=value, - label=config_field["label"], + label=config_field.get("label"), ) elif field_type == "text": attr_def = TextDef( config_field["name"], default=config_field["value"], - label=config_field["label"], - placeholder=config_field["placeholder"], - multiline=config_field["multiline"], - regex=config_field["regex"], + label=config_field.get("label"), + placeholder=config_field.get("placeholder"), + multiline=config_field.get("multiline", False), + regex=config_field.get("regex"), # syntax=config_field["syntax"], ) elif field_type in ("integer", "float"): @@ -641,9 +641,9 @@ class ActionsWidget(QtWidgets.QWidget): attr_def = NumberDef( config_field["name"], default=value, - label=config_field["label"], + label=config_field.get("label"), decimals=0 if field_type == "integer" else 5, - placeholder=config_field["placeholder"], + placeholder=config_field.get("placeholder"), min_value=config_field.get("min"), max_value=config_field.get("max"), ) @@ -651,8 +651,8 @@ class ActionsWidget(QtWidgets.QWidget): attr_def = EnumDef( config_field["name"], items=config_field["options"], - default=config_field["value"], - label=config_field["label"], + default=config_field.get("value"), + label=config_field.get("label"), multiselection=field_type == "multiselect", ) elif field_type == "hidden": From 5f18f638d30ef445d946e75fd643c829b9dfdee4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 9 May 2025 10:59:23 +0200 Subject: [PATCH 047/103] formatting fix --- client/ayon_core/tools/launcher/models/actions.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/launcher/models/actions.py b/client/ayon_core/tools/launcher/models/actions.py index d5a412e33a..ad1ea3835f 100644 --- a/client/ayon_core/tools/launcher/models/actions.py +++ b/client/ayon_core/tools/launcher/models/actions.py @@ -597,7 +597,9 @@ class ActionsModel: ) else: - self.log.warning(f"Unknown webaction response type '{response_type}'") + self.log.warning( + f"Unknown webaction response type '{response_type}'" + ) response.error_message = "Unknown webaction response type." return response From 73a45f5f6f201f5a54f47a45a4c2e1f44c4fe825 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 9 May 2025 11:59:46 +0200 Subject: [PATCH 048/103] more config fields conversion fixes --- .../tools/launcher/ui/actions_widget.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 8973b34f07..3d96b90b6e 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -606,9 +606,9 @@ class ActionsWidget(QtWidgets.QWidget): field_type = config_field["type"] attr_def = None if field_type == "label": - label = config_field.get("text") + label = config_field.get("value") if label is None: - label = config_field["value"] + label = config_field.get("text") attr_def = UILabelDef( label, key=uuid.uuid4().hex ) @@ -625,7 +625,7 @@ class ActionsWidget(QtWidgets.QWidget): elif field_type == "text": attr_def = TextDef( config_field["name"], - default=config_field["value"], + default=config_field.get("value"), label=config_field.get("label"), placeholder=config_field.get("placeholder"), multiline=config_field.get("multiline", False), @@ -633,11 +633,12 @@ class ActionsWidget(QtWidgets.QWidget): # syntax=config_field["syntax"], ) elif field_type in ("integer", "float"): - value = config_field["value"] - if field_type == "integer": - value = int(value) - else: - value = float(value) + value = config_field.get("value") + if isinstance(value, str): + if field_type == "integer": + value = int(value) + else: + value = float(value) attr_def = NumberDef( config_field["name"], default=value, @@ -658,7 +659,7 @@ class ActionsWidget(QtWidgets.QWidget): elif field_type == "hidden": attr_def = HiddenDef( config_field["name"], - default=config_field["value"], + default=config_field.get("value"), ) if attr_def is None: From f036a1ffb9c3b14e16298648e29edde40f6c5762 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 21 May 2025 17:53:03 +0200 Subject: [PATCH 049/103] add soft dependency for applications addon --- package.py | 1 + 1 file changed, 1 insertion(+) diff --git a/package.py b/package.py index 32fedd859b..0eb559a3d9 100644 --- a/package.py +++ b/package.py @@ -11,6 +11,7 @@ ayon_launcher_version = ">=1.0.2" ayon_required_addons = {} ayon_compatible_addons = { "ayon_ocio": ">=1.2.1", + "applications": ">=1.1.2", "harmony": ">0.4.0", "fusion": ">=0.3.3", "openrv": ">=1.0.2", From f35afa3a9ffd0c8f3a2eff29ea178e9b139bc6ad Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 21 May 2025 17:53:16 +0200 Subject: [PATCH 050/103] bump required server version --- package.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.py b/package.py index 0eb559a3d9..e348950571 100644 --- a/package.py +++ b/package.py @@ -6,7 +6,7 @@ client_dir = "ayon_core" plugin_for = ["ayon_server"] -ayon_server_version = ">=1.7.6,<2.0.0" +ayon_server_version = ">=1.8.4,<2.0.0" ayon_launcher_version = ">=1.0.2" ayon_required_addons = {} ayon_compatible_addons = { From 3e4a1d7118d9fae7250ea129b598390388e4fc32 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 21 May 2025 18:25:03 +0200 Subject: [PATCH 051/103] use toast messages --- .../tools/launcher/models/actions.py | 12 ++- client/ayon_core/tools/launcher/ui/window.py | 94 ++++++++++++------- 2 files changed, 69 insertions(+), 37 deletions(-) diff --git a/client/ayon_core/tools/launcher/models/actions.py b/client/ayon_core/tools/launcher/models/actions.py index ad1ea3835f..25335a7ecf 100644 --- a/client/ayon_core/tools/launcher/models/actions.py +++ b/client/ayon_core/tools/launcher/models/actions.py @@ -1,5 +1,6 @@ import os import copy +import uuid from dataclasses import dataclass, asdict from urllib.parse import urlencode, urlparse from typing import Any, Optional @@ -244,6 +245,7 @@ class ActionsModel: error_message = None action_label = identifier action_items = self._get_action_items(project_name) + trigger_id = uuid.uuid4().hex try: action = self._actions[identifier] action_item = action_items[identifier] @@ -251,6 +253,7 @@ class ActionsModel: self._controller.emit_event( "action.trigger.started", { + "trigger_id": trigger_id, "identifier": identifier, "full_label": action_label, } @@ -265,6 +268,7 @@ class ActionsModel: self._controller.emit_event( "action.trigger.finished", { + "trigger_id": trigger_id, "identifier": identifier, "failed": failed, "error_message": error_message, @@ -307,10 +311,13 @@ class ActionsModel: if form_data is not None: context["formData"] = form_data + trigger_id = uuid.uuid4().hex + failed = False try: self._controller.emit_event( "webaction.trigger.started", { + "trigger_id": trigger_id, "identifier": identifier, "full_label": action_label, } @@ -329,6 +336,7 @@ class ActionsModel: handle_response = self._handle_webaction_response(response.data) except Exception: + failed = True self.log.warning("Action trigger failed.", exc_info=True) handle_response = WebactionResponse( "unknown", @@ -338,8 +346,10 @@ class ActionsModel: data = handle_response.to_data() data.update({ + "trigger_failed": failed, + "trigger_id": trigger_id, "identifier": identifier, - "action_label": action_label, + "full_label": action_label, "project_name": project_name, "folder_id": folder_id, "task_id": task_id, diff --git a/client/ayon_core/tools/launcher/ui/window.py b/client/ayon_core/tools/launcher/ui/window.py index 3f3e4bb1de..7236e3dbf4 100644 --- a/client/ayon_core/tools/launcher/ui/window.py +++ b/client/ayon_core/tools/launcher/ui/window.py @@ -1,9 +1,9 @@ from qtpy import QtWidgets, QtCore, QtGui -from ayon_core import style -from ayon_core import resources +from ayon_core import style, resources from ayon_core.tools.launcher.control import BaseLauncherController +from ayon_core.tools.utils import MessageOverlayObject from .projects_widget import ProjectsWidget from .hierarchy_page import HierarchyPage @@ -41,6 +41,8 @@ class LauncherWindow(QtWidgets.QWidget): self._controller = controller + overlay_object = MessageOverlayObject(self) + # Main content - Pages & Actions content_body = QtWidgets.QSplitter(self) @@ -78,26 +80,18 @@ class LauncherWindow(QtWidgets.QWidget): content_body.setSizes([580, 160]) # Footer - footer_widget = QtWidgets.QWidget(self) - - # - Message label - message_label = QtWidgets.QLabel(footer_widget) - + # footer_widget = QtWidgets.QWidget(self) + # # action_history = ActionHistory(footer_widget) # action_history.setStatusTip("Show Action History") - - footer_layout = QtWidgets.QHBoxLayout(footer_widget) - footer_layout.setContentsMargins(0, 0, 0, 0) - footer_layout.addWidget(message_label, 1) + # + # footer_layout = QtWidgets.QHBoxLayout(footer_widget) + # footer_layout.setContentsMargins(0, 0, 0, 0) # footer_layout.addWidget(action_history, 0) layout = QtWidgets.QVBoxLayout(self) layout.addWidget(content_body, 1) - layout.addWidget(footer_widget, 0) - - message_timer = QtCore.QTimer() - message_timer.setInterval(self.message_interval) - message_timer.setSingleShot(True) + # layout.addWidget(footer_widget, 0) actions_refresh_timer = QtCore.QTimer() actions_refresh_timer.setInterval(self.refresh_interval) @@ -109,7 +103,6 @@ class LauncherWindow(QtWidgets.QWidget): page_slide_anim.setEasingCurve(QtCore.QEasingCurve.OutQuad) projects_page.refreshed.connect(self._on_projects_refresh) - message_timer.timeout.connect(self._on_message_timeout) actions_refresh_timer.timeout.connect( self._on_actions_refresh_timeout) page_slide_anim.valueChanged.connect( @@ -137,6 +130,8 @@ class LauncherWindow(QtWidgets.QWidget): self._on_webaction_trigger_finished, ) + self._overlay_object = overlay_object + self._controller = controller self._is_on_projects_page = True @@ -149,11 +144,8 @@ class LauncherWindow(QtWidgets.QWidget): self._projects_page = projects_page self._hierarchy_page = hierarchy_page self._actions_widget = actions_widget - - self._message_label = message_label # self._action_history = action_history - self._message_timer = message_timer self._actions_refresh_timer = actions_refresh_timer self._page_slide_anim = page_slide_anim @@ -193,13 +185,6 @@ class LauncherWindow(QtWidgets.QWidget): else: self._refresh_on_activate = True - def _echo(self, message): - self._message_label.setText(str(message)) - self._message_timer.start() - - def _on_message_timeout(self): - self._message_label.setText("") - def _on_project_selection_change(self, event): project_name = event["project_name"] self._selected_project_name = project_name @@ -223,16 +208,38 @@ class LauncherWindow(QtWidgets.QWidget): self._hierarchy_page.refresh() self._actions_widget.refresh() + def _show_toast_message(self, message, success=True, message_id=None): + message_type = None + if not success: + message_type = "error" + + self._overlay_object.add_message( + message, message_type, message_id=message_id + ) + def _on_action_trigger_started(self, event): - self._echo("Running action: {}".format(event["full_label"])) + self._show_toast_message( + "Running action: {}".format(event["full_label"]), + message_id=event["trigger_id"], + ) def _on_action_trigger_finished(self, event): - if not event["failed"]: - return - self._echo("Failed: {}".format(event["error_message"])) + action_label = event["full_label"] + if event["failed"]: + message = f"Failed to run action: {action_label}" + else: + message = f"Action finished: {action_label}" + self._show_toast_message( + message, + not event["failed"], + message_id=event["trigger_id"], + ) def _on_webaction_trigger_started(self, event): - self._echo("Running webaction: {}".format(event["full_label"])) + self._show_toast_message( + "Running webaction: {}".format(event["full_label"]), + message_id=event["trigger_id"], + ) def _on_webaction_trigger_finished(self, event): clipboard_text = event["clipboard_text"] @@ -240,12 +247,27 @@ class LauncherWindow(QtWidgets.QWidget): clipboard = QtWidgets.QApplication.clipboard() clipboard.setText(clipboard_text) - # TODO use toast messages - if event["message"]: - self._echo(event["message"]) + action_label = event["full_label"] + # Avoid to show exception message + if event["trigger_failed"]: + self._show_toast_message( + f"Failed to run action: {action_label}", + message_id=event["trigger_id"] + ) + return + # Failed to run webaction, e.g. because of missing webaction handling + # - not reported by server if event["error_message"]: - self._echo(event["message"]) + self._show_toast_message( + event["error_message"], + success=False, + message_id=event["trigger_id"] + ) + return + + if event["message"]: + self._show_toast_message(event["message"]) if event["form"]: self._actions_widget.handle_webaction_form_event(event) From 1de2eccedb5d109f1b5d33dc6e9674d7c00128b0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 21 May 2025 18:27:48 +0200 Subject: [PATCH 052/103] Unified messages a little --- client/ayon_core/tools/launcher/ui/window.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/tools/launcher/ui/window.py b/client/ayon_core/tools/launcher/ui/window.py index 7236e3dbf4..c17fe6e549 100644 --- a/client/ayon_core/tools/launcher/ui/window.py +++ b/client/ayon_core/tools/launcher/ui/window.py @@ -219,16 +219,16 @@ class LauncherWindow(QtWidgets.QWidget): def _on_action_trigger_started(self, event): self._show_toast_message( - "Running action: {}".format(event["full_label"]), + "Running: {}".format(event["full_label"]), message_id=event["trigger_id"], ) def _on_action_trigger_finished(self, event): action_label = event["full_label"] if event["failed"]: - message = f"Failed to run action: {action_label}" + message = f"Failed to run: {action_label}" else: - message = f"Action finished: {action_label}" + message = f"Finished: {action_label}" self._show_toast_message( message, not event["failed"], @@ -237,7 +237,7 @@ class LauncherWindow(QtWidgets.QWidget): def _on_webaction_trigger_started(self, event): self._show_toast_message( - "Running webaction: {}".format(event["full_label"]), + "Running: {}".format(event["full_label"]), message_id=event["trigger_id"], ) @@ -251,7 +251,7 @@ class LauncherWindow(QtWidgets.QWidget): # Avoid to show exception message if event["trigger_failed"]: self._show_toast_message( - f"Failed to run action: {action_label}", + f"Failed to run: {action_label}", message_id=event["trigger_id"] ) return From e971ffd09ce49dc879ed7ea9fa2a22b67a4352e5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 21 May 2025 18:27:58 +0200 Subject: [PATCH 053/103] use success from response --- client/ayon_core/tools/launcher/ui/window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/launcher/ui/window.py b/client/ayon_core/tools/launcher/ui/window.py index c17fe6e549..7fde8518b0 100644 --- a/client/ayon_core/tools/launcher/ui/window.py +++ b/client/ayon_core/tools/launcher/ui/window.py @@ -267,7 +267,7 @@ class LauncherWindow(QtWidgets.QWidget): return if event["message"]: - self._show_toast_message(event["message"]) + self._show_toast_message(event["message"], event["success"]) if event["form"]: self._actions_widget.handle_webaction_form_event(event) From b1e4598fbd6c044a101252ee709175934b9fd64c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 21 May 2025 19:35:58 +0200 Subject: [PATCH 054/103] remove duplicated animation start --- client/ayon_core/tools/launcher/ui/actions_widget.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 3d96b90b6e..fca27e3b6e 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -510,7 +510,6 @@ class ActionsWidget(QtWidgets.QWidget): self._controller.trigger_action(*args) self._start_animation(index) - self._start_animation(index) def _show_menu_on_group(self, action_id): action_items = self._model.get_group_items(action_id) From ecec0aa69d4086b5ba3d94ce84837d18c283046f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 21 May 2025 19:39:11 +0200 Subject: [PATCH 055/103] use logger formatting --- client/ayon_core/tools/utils/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/utils/lib.py b/client/ayon_core/tools/utils/lib.py index 9ee89fbf3a..ecf5dc1bc5 100644 --- a/client/ayon_core/tools/utils/lib.py +++ b/client/ayon_core/tools/utils/lib.py @@ -550,7 +550,7 @@ class _IconsCache: icon = QtGui.QIcon(pix) except Exception: log.warning( - f"Failed to download image '{url}'", exc_info=True + f"Failed to download image '%s'", url, exc_info=True ) icon = None From 44da9f5a0d0b9cd88326af6e0121a1011b252582 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 21 May 2025 19:41:53 +0200 Subject: [PATCH 056/103] add type-hints to '_prepare_ayon_launcher_env' --- client/ayon_core/lib/execute.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/lib/execute.py b/client/ayon_core/lib/execute.py index 27af3d44ca..1eb2659a11 100644 --- a/client/ayon_core/lib/execute.py +++ b/client/ayon_core/lib/execute.py @@ -1,3 +1,4 @@ +from __future__ import annotations import os import sys import subprocess @@ -201,7 +202,9 @@ def clean_envs_for_ayon_process(env=None): return env -def _prepare_ayon_launcher_env(add_sys_paths: bool, kwargs): +def _prepare_ayon_launcher_env( + add_sys_paths: bool, kwargs: dict +) -> dict[str, str]: env = kwargs.pop("env", None) # Keep env untouched if are passed and not empty if not env: From 80d32558636da39dc6245893a36b3761579e1951 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 21 May 2025 19:45:33 +0200 Subject: [PATCH 057/103] fix return type --- client/ayon_core/lib/execute.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/lib/execute.py b/client/ayon_core/lib/execute.py index 1eb2659a11..c0d86ea07a 100644 --- a/client/ayon_core/lib/execute.py +++ b/client/ayon_core/lib/execute.py @@ -346,7 +346,8 @@ def run_detached_ayon_launcher_process( **kwargs (Any): Keyword arguments for subprocess.Popen. Returns: - str: Full output of subprocess concatenated stdout and stderr. + subprocess.Popen: Pointer to launched process but it is possible that + launched process is already killed (on linux). """ args = get_ayon_launcher_args(*args) From 4b1f1a95848d261ac6c7fcbbdf7d57d882baf904 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 21 May 2025 19:45:39 +0200 Subject: [PATCH 058/103] add typehints --- client/ayon_core/lib/execute.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/lib/execute.py b/client/ayon_core/lib/execute.py index c0d86ea07a..7c6efde35c 100644 --- a/client/ayon_core/lib/execute.py +++ b/client/ayon_core/lib/execute.py @@ -295,8 +295,8 @@ def run_detached_process(args, **kwargs): def run_ayon_launcher_process( - *args, add_sys_paths=False, **kwargs -): + *args, add_sys_paths: bool = False, **kwargs +) -> str: """Execute AYON process with passed arguments and wait. Wrapper for 'run_process' which prepends AYON executable arguments @@ -325,8 +325,8 @@ def run_ayon_launcher_process( def run_detached_ayon_launcher_process( - *args, add_sys_paths=False, **kwargs -): + *args, add_sys_paths: bool = False, **kwargs +) -> subprocess.Popen: """Execute AYON process with passed arguments and wait. Wrapper for 'run_process' which prepends AYON executable arguments From 316c7e7e389e227010bfc8b700a398afe8a96ea8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 21 May 2025 19:47:53 +0200 Subject: [PATCH 059/103] remove unnecessary f string --- client/ayon_core/tools/utils/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/utils/lib.py b/client/ayon_core/tools/utils/lib.py index ecf5dc1bc5..f7919a3317 100644 --- a/client/ayon_core/tools/utils/lib.py +++ b/client/ayon_core/tools/utils/lib.py @@ -550,7 +550,7 @@ class _IconsCache: icon = QtGui.QIcon(pix) except Exception: log.warning( - f"Failed to download image '%s'", url, exc_info=True + "Failed to download image '%s'", url, exc_info=True ) icon = None From e9d70fa49d0155c55f38240f50bdf01493296cb1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 22 May 2025 12:24:49 +0200 Subject: [PATCH 060/103] keep webactions cached for 20 seconds --- client/ayon_core/tools/launcher/models/actions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/launcher/models/actions.py b/client/ayon_core/tools/launcher/models/actions.py index 25335a7ecf..6457319a01 100644 --- a/client/ayon_core/tools/launcher/models/actions.py +++ b/client/ayon_core/tools/launcher/models/actions.py @@ -189,7 +189,7 @@ class ActionsModel: self._actions = None self._action_items = {} self._webaction_items = NestedCacheItem( - levels=2, default_factory=list + levels=2, default_factory=list, lifetime=20, ) self._addons_manager = None From 0368e5e6f819d511c876c66279e40869249b1403 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 22 May 2025 12:25:00 +0200 Subject: [PATCH 061/103] hard reset webactions on refresh --- client/ayon_core/tools/launcher/models/actions.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/tools/launcher/models/actions.py b/client/ayon_core/tools/launcher/models/actions.py index 6457319a01..04ed9e115c 100644 --- a/client/ayon_core/tools/launcher/models/actions.py +++ b/client/ayon_core/tools/launcher/models/actions.py @@ -206,6 +206,7 @@ class ActionsModel: self._discovered_actions = None self._actions = None self._action_items = {} + self._webaction_items.reset() self._controller.emit_event("actions.refresh.started") self._get_action_objects() From f22206e54ca5434734fb197662648f6e3e199d7d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 22 May 2025 15:30:33 +0200 Subject: [PATCH 062/103] use 'MarkdownText' format if is available --- client/ayon_core/tools/utils/widgets.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/utils/widgets.py b/client/ayon_core/tools/utils/widgets.py index a19b0a8966..aea2eca6a2 100644 --- a/client/ayon_core/tools/utils/widgets.py +++ b/client/ayon_core/tools/utils/widgets.py @@ -143,10 +143,18 @@ class MarkdownLabel(QtWidgets.QLabel): # Enable word wrap by default self.setWordWrap(True) + text_format_available = hasattr(QtCore.Qt, "MarkdownText") + if text_format_available: + self.setTextFormat(QtCore.Qt.MarkdownText) + + self._text_format_available = text_format_available + self.setText(self.text()) def setText(self, text): - super().setText(self._md_to_html(text)) + if not self._text_format_available: + text = self._md_to_html(text) + super().setText(text) @staticmethod def _md_to_html(text): From d02eeac660d6028fc065eef079aa2720569e87bb Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 22 May 2025 17:02:01 +0200 Subject: [PATCH 063/103] move ActionItem to abstract.py --- client/ayon_core/tools/launcher/abstract.py | 77 +++++++++++++++++++ .../tools/launcher/models/actions.py | 77 +------------------ 2 files changed, 78 insertions(+), 76 deletions(-) diff --git a/client/ayon_core/tools/launcher/abstract.py b/client/ayon_core/tools/launcher/abstract.py index 004d03bccb..678ece6510 100644 --- a/client/ayon_core/tools/launcher/abstract.py +++ b/client/ayon_core/tools/launcher/abstract.py @@ -1,4 +1,81 @@ +from __future__ import annotations + +import copy from abc import ABC, abstractmethod +from typing import Optional, Any + + +class ActionItem: + """Item representing single action to trigger. + + Args: + action_type (Literal["webaction", "local"]): Type of action. + identifier (str): Unique identifier of action item. + label (str): Action label. + variant_label (Union[str, None]): Variant label, full label is + concatenated with space. Actions are grouped under single + action if it has same 'label' and have set 'variant_label'. + icon (dict[str, str]): Icon definition. + order (int): Action ordering. + addon_name (Optional[str]): Addon name. + addon_version (Optional[str]): Addon version. + config_fields (Optional[list[dict]]): Config fields for webaction. + full_label (Optional[str]): Full label, if not set it is generated + from 'label' and 'variant_label'. + + """ + def __init__( + self, + action_type: str, + identifier: str, + label: str, + variant_label: Optional[str], + icon: dict[str, str], + order: int, + addon_name: Optional[str] = None, + addon_version: Optional[str] = None, + config_fields: Optional[list[dict]] = None, + full_label: Optional[str] = None, + ): + if config_fields is None: + config_fields = [] + self.action_type = action_type + self.identifier = identifier + self.label = label + self.variant_label = variant_label + self.icon = icon + self.order = order + self.addon_name = addon_name + self.addon_version = addon_version + self.config_fields = config_fields + self._full_label = full_label + + def copy(self): + return self.from_data(self.to_data()) + + @property + def full_label(self): + if self._full_label is None: + if self.variant_label: + self._full_label = " ".join([self.label, self.variant_label]) + else: + self._full_label = self.label + return self._full_label + + def to_data(self) -> dict[str, Any]: + return { + "identifier": self.identifier, + "label": self.label, + "variant_label": self.variant_label, + "icon": self.icon, + "order": self.order, + "full_label": self._full_label, + "config_fields": copy.deepcopy(self.config_fields), + } + + @classmethod + def from_data(cls, data: dict[str, Any]) -> "ActionItem": + return cls(**data) class AbstractLauncherCommon(ABC): diff --git a/client/ayon_core/tools/launcher/models/actions.py b/client/ayon_core/tools/launcher/models/actions.py index 04ed9e115c..a1ef789574 100644 --- a/client/ayon_core/tools/launcher/models/actions.py +++ b/client/ayon_core/tools/launcher/models/actions.py @@ -1,5 +1,4 @@ import os -import copy import uuid from dataclasses import dataclass, asdict from urllib.parse import urlencode, urlparse @@ -23,81 +22,7 @@ from ayon_core.pipeline.actions import ( register_launcher_action_path, ) - -class ActionItem: - """Item representing single action to trigger. - - Todos: - Get rid of application specific logic. - - Args: - action_type (Literal["webaction", "local"]): Type of action. - identifier (str): Unique identifier of action item. - label (str): Action label. - variant_label (Union[str, None]): Variant label, full label is - concatenated with space. Actions are grouped under single - action if it has same 'label' and have set 'variant_label'. - icon (dict[str, str]): Icon definition. - order (int): Action ordering. - addon_name (str): Addon name. - addon_version (str): Addon version. - config_fields (list[dict]): Config fields for webaction. - full_label (Optional[str]): Full label, if not set it is generated - from 'label' and 'variant_label'. - """ - - def __init__( - self, - action_type, - identifier, - label, - variant_label, - icon, - order, - addon_name=None, - addon_version=None, - config_fields=None, - full_label=None - ): - if config_fields is None: - config_fields = [] - self.action_type = action_type - self.identifier = identifier - self.label = label - self.variant_label = variant_label - self.icon = icon - self.order = order - self.addon_name = addon_name - self.addon_version = addon_version - self.config_fields = config_fields - self._full_label = full_label - - def copy(self): - return self.from_data(self.to_data()) - - @property - def full_label(self): - if self._full_label is None: - if self.variant_label: - self._full_label = " ".join([self.label, self.variant_label]) - else: - self._full_label = self.label - return self._full_label - - def to_data(self): - return { - "identifier": self.identifier, - "label": self.label, - "variant_label": self.variant_label, - "icon": self.icon, - "order": self.order, - "full_label": self._full_label, - "config_fields": copy.deepcopy(self.config_fields), - } - - @classmethod - def from_data(cls, data): - return cls(**data) +from ayon_core.tools.launcher.abstract import ActionItem @dataclass From 4954992e56591dde4bb56f79b28cb5538528a734 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 22 May 2025 17:02:59 +0200 Subject: [PATCH 064/103] added some typehints --- client/ayon_core/tools/launcher/abstract.py | 59 ++++++++++++++------- 1 file changed, 39 insertions(+), 20 deletions(-) diff --git a/client/ayon_core/tools/launcher/abstract.py b/client/ayon_core/tools/launcher/abstract.py index 678ece6510..36dc696cf6 100644 --- a/client/ayon_core/tools/launcher/abstract.py +++ b/client/ayon_core/tools/launcher/abstract.py @@ -165,7 +165,9 @@ class AbstractLauncherBackend(AbstractLauncherCommon): class AbstractLauncherFrontEnd(AbstractLauncherCommon): # Entity items for UI @abstractmethod - def get_project_items(self, sender=None): + def get_project_items( + self, sender: Optional[str] = None + ) -> list[ProjectItem]: """Project items for all projects. This function may trigger events 'projects.refresh.started' and @@ -183,7 +185,9 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def get_folder_type_items(self, project_name, sender=None): + def get_folder_type_items( + self, project_name: str, sender: Optional[str] = None + ) -> list[FolderTypeItem]: """Folder type items for a project. This function may trigger events with topics @@ -203,7 +207,9 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def get_task_type_items(self, project_name, sender=None): + def get_task_type_items( + self, project_name: str, sender: Optional[str] = None + ) -> list[TaskTypeItem]: """Task type items for a project. This function may trigger events with topics @@ -223,7 +229,9 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def get_folder_items(self, project_name, sender=None): + def get_folder_items( + self, project_name: str, sender: Optional[str] = None + ) -> list[FolderItem]: """Folder items to visualize project hierarchy. This function may trigger events 'folders.refresh.started' and @@ -242,7 +250,9 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def get_task_items(self, project_name, folder_id, sender=None): + def get_task_items( + self, project_name: str, folder_id: str, sender: Optional[str] = None + ) -> list[TaskItem]: """Task items. This function may trigger events 'tasks.refresh.started' and @@ -262,7 +272,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def get_selected_project_name(self): + def get_selected_project_name(self) -> Optional[str]: """Selected project name. Returns: @@ -272,7 +282,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def get_selected_folder_id(self): + def get_selected_folder_id(self) -> Optional[str]: """Selected folder id. Returns: @@ -282,7 +292,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def get_selected_task_id(self): + def get_selected_task_id(self) -> Optional[str]: """Selected task id. Returns: @@ -292,7 +302,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def get_selected_task_name(self): + def get_selected_task_name(self) -> Optional[str]: """Selected task name. Returns: @@ -302,7 +312,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def get_selected_context(self): + def get_selected_context(self) -> dict[str, Optional[str]]: """Get whole selected context. Example: @@ -320,7 +330,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def set_selected_project(self, project_name): + def set_selected_project(self, project_name: Optional[str]): """Change selected folder. Args: @@ -331,7 +341,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def set_selected_folder(self, folder_id): + def set_selected_folder(self, folder_id: Optional[str]): """Change selected folder. Args: @@ -342,7 +352,9 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def set_selected_task(self, task_id, task_name): + def set_selected_task( + self, task_id: Optional[str], task_name: Optional[str] + ): """Change selected task. Args: @@ -356,7 +368,12 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): # Actions @abstractmethod - def get_action_items(self, project_name, folder_id, task_id): + def get_action_items( + self, + project_name: Optional[str], + folder_id: Optional[str], + task_id: Optional[str], + ) -> list[ActionItem]: """Get action items for given context. Args: @@ -374,10 +391,10 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): @abstractmethod def trigger_action( self, - action_id, - project_name, - folder_id, - task_id, + action_id: str, + project_name: Optional[str], + folder_id: Optional[str], + task_id: Optional[str], ): """Trigger action on given context. @@ -462,14 +479,16 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def get_my_tasks_entity_ids(self, project_name: str): + def get_my_tasks_entity_ids( + self, project_name: str + ) -> dict[str, list[str]]: """Get entity ids for my tasks. Args: project_name (str): Project name. Returns: - dict[str, Union[list[str]]]: Folder and task ids. + dict[str, list[str]]: Folder and task ids. """ pass From ea5d5cfc7129819f2c2aa02577aa41d361571221 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 22 May 2025 17:03:53 +0200 Subject: [PATCH 065/103] added missing imports --- client/ayon_core/tools/launcher/abstract.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/client/ayon_core/tools/launcher/abstract.py b/client/ayon_core/tools/launcher/abstract.py index 36dc696cf6..a57f539261 100644 --- a/client/ayon_core/tools/launcher/abstract.py +++ b/client/ayon_core/tools/launcher/abstract.py @@ -4,6 +4,13 @@ import copy from abc import ABC, abstractmethod from typing import Optional, Any +from ayon_core.tools.common_models import ( + ProjectItem, + FolderItem, + FolderTypeItem, + TaskItem, + TaskTypeItem, +) class ActionItem: """Item representing single action to trigger. From 1f716ce103db4ff91a29a80af9892047247f6102 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 22 May 2025 17:10:51 +0200 Subject: [PATCH 066/103] added helper class 'WebactionContext' to pass webaction info --- client/ayon_core/tools/launcher/abstract.py | 66 ++++++++------ client/ayon_core/tools/launcher/control.py | 61 ++----------- .../tools/launcher/models/actions.py | 91 ++++++++----------- .../tools/launcher/ui/actions_widget.py | 44 +++++---- 4 files changed, 105 insertions(+), 157 deletions(-) diff --git a/client/ayon_core/tools/launcher/abstract.py b/client/ayon_core/tools/launcher/abstract.py index a57f539261..c4404bb9fa 100644 --- a/client/ayon_core/tools/launcher/abstract.py +++ b/client/ayon_core/tools/launcher/abstract.py @@ -2,6 +2,7 @@ from __future__ import annotations import copy from abc import ABC, abstractmethod +from dataclasses import dataclass from typing import Optional, Any from ayon_core.tools.common_models import ( @@ -12,6 +13,18 @@ from ayon_core.tools.common_models import ( TaskTypeItem, ) + +@dataclass +class WebactionContext: + """Context used for methods related to webactions.""" + identifier: str + project_name: str + folder_id: str + task_id: str + addon_name: str + addon_version: str + + class ActionItem: """Item representing single action to trigger. @@ -417,25 +430,15 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): @abstractmethod def trigger_webaction( self, - identifier, - project_name, - folder_id, - task_id, - action_label, - addon_name, - addon_version, - form_data=None, + context: WebactionContext, + action_label: str, + form_data: Optional[dict[str, Any]] = None, ): """Trigger action on given context. Args: - identifier (str): Action identifier. - project_name (Union[str, None]): Project name. - folder_id (Union[str, None]): Folder id. - task_id (Union[str, None]): Task id. + context (WebactionContext): Webaction context. action_label (str): Action label. - addon_name (str): Addon name. - addon_version (str): Addon version. form_data (Optional[dict[str, Any]]): Form values of action. """ @@ -443,27 +446,32 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): @abstractmethod def get_action_config_values( - self, - action_id, - project_name, - folder_id, - task_id, - addon_name, - addon_version, - ): + self, context: WebactionContext + ) -> dict[str, Any]: + """Get action config values. + + Args: + context (WebactionContext): Webaction context. + + Returns: + dict[str, Any]: Action config values. + + """ pass @abstractmethod def set_action_config_values( self, - action_id, - project_name, - folder_id, - task_id, - addon_name, - addon_version, - values, + context: WebactionContext, + values: dict[str, Any], ): + """Set action config values. + + Args: + context (WebactionContext): Webaction context. + values (dict[str, Any]): Action config values. + + """ pass @abstractmethod diff --git a/client/ayon_core/tools/launcher/control.py b/client/ayon_core/tools/launcher/control.py index a69288dda1..fb9f950bd1 100644 --- a/client/ayon_core/tools/launcher/control.py +++ b/client/ayon_core/tools/launcher/control.py @@ -149,65 +149,16 @@ class BaseLauncherController( task_id, ) - def trigger_webaction( - self, - identifier, - project_name, - folder_id, - task_id, - action_label, - addon_name, - addon_version, - form_data=None, - ): + def trigger_webaction(self, context, action_label, form_data=None): self._actions_model.trigger_webaction( - identifier, - project_name, - folder_id, - task_id, - action_label, - addon_name, - addon_version, - form_data, + context, action_label, form_data ) - def get_action_config_values( - self, - action_id, - project_name, - folder_id, - task_id, - addon_name, - addon_version, - ): - return self._actions_model.get_action_config_values( - action_id, - project_name, - folder_id, - task_id, - addon_name, - addon_version, - ) + def get_action_config_values(self, context): + return self._actions_model.get_action_config_values(context) - def set_action_config_values( - self, - action_id, - project_name, - folder_id, - task_id, - addon_name, - addon_version, - values, - ): - return self._actions_model.set_action_config_values( - action_id, - project_name, - folder_id, - task_id, - addon_name, - addon_version, - values, - ) + def set_action_config_values(self, context, values): + return self._actions_model.set_action_config_values(context, values) # General methods def refresh(self): diff --git a/client/ayon_core/tools/launcher/models/actions.py b/client/ayon_core/tools/launcher/models/actions.py index a1ef789574..2706af5580 100644 --- a/client/ayon_core/tools/launcher/models/actions.py +++ b/client/ayon_core/tools/launcher/models/actions.py @@ -21,8 +21,7 @@ from ayon_core.pipeline.actions import ( LauncherActionSelection, register_launcher_action_path, ) - -from ayon_core.tools.launcher.abstract import ActionItem +from ayon_core.tools.launcher.abstract import ActionItem, WebactionContext @dataclass @@ -202,19 +201,16 @@ class ActionsModel: } ) - def trigger_webaction( - self, - identifier, - project_name, - folder_id, - task_id, - action_label, - addon_name, - addon_version, - form_data, - ): + def trigger_webaction(self, context, action_label, form_data): entity_type = None entity_ids = [] + identifier = context.identifier + folder_id = context.folder_id + task_id = context.task_id + project_name = context.project_name + addon_name = context.addon_name + addon_version = context.addon_version + if task_id: entity_type = "task" entity_ids.append(task_id) @@ -229,13 +225,13 @@ class ActionsModel: "variant": self._variant, } url = f"actions/execute?{urlencode(query)}" - context = { + request_data = { "projectName": project_name, "entityType": entity_type, "entityIds": entity_ids, } if form_data is not None: - context["formData"] = form_data + request_data["formData"] = form_data trigger_id = uuid.uuid4().hex failed = False @@ -257,7 +253,9 @@ class ActionsModel: headers = None else: headers["referer"] = conn.get_base_url() - response = ayon_api.raw_post(url, headers=headers, json=context) + response = ayon_api.raw_post( + url, headers=headers, json=request_data + ) response.raise_for_status() handle_response = self._handle_webaction_response(response.data) @@ -287,30 +285,24 @@ class ActionsModel: data, ) - def get_action_config_values( - self, - identifier, - project_name, - folder_id, - task_id, - addon_name, - addon_version, - ): - selection = self._prepare_selection(project_name, folder_id, task_id) + def get_action_config_values(self, context: WebactionContext): + selection = self._prepare_selection( + context.project_name, context.folder_id, context.task_id + ) if not selection.is_project_selected: return {} - context = self._get_webaction_context(selection) + request_data = self._get_webaction_request_data(selection) query = { - "addonName": addon_name, - "addonVersion": addon_version, - "identifier": identifier, + "addonName": context.addon_name, + "addonVersion": context.addon_version, + "identifier": context.identifier, "variant": self._variant, } url = f"actions/config?{urlencode(query)}" try: - response = ayon_api.post(url, **context) + response = ayon_api.post(url, **request_data) response.raise_for_status() except Exception: self.log.warning( @@ -320,32 +312,25 @@ class ActionsModel: return {} return response.data - def set_action_config_values( - self, - identifier, - project_name, - folder_id, - task_id, - addon_name, - addon_version, - values, - ): - selection = self._prepare_selection(project_name, folder_id, task_id) + def set_action_config_values(self, context, values): + selection = self._prepare_selection( + context.project_name, context.folder_id, context.task_id + ) if not selection.is_project_selected: return {} - context = self._get_webaction_context(selection) - context["value"] = values + request_data = self._get_webaction_request_data(selection) + request_data["value"] = values query = { - "addonName": addon_name, - "addonVersion": addon_version, - "identifier": identifier, + "addonName": context.addon_name, + "addonVersion": context.addon_version, + "identifier": context.identifier, "variant": self._variant, } url = f"actions/config?{urlencode(query)}" try: - response = ayon_api.post(url, **context) + response = ayon_api.post(url, **request_data) response.raise_for_status() except Exception: self.log.warning( @@ -371,7 +356,7 @@ class ActionsModel: project_settings=project_settings, ) - def _get_webaction_context(self, selection: LauncherActionSelection): + def _get_webaction_request_data(self, selection: LauncherActionSelection): if not selection.is_project_selected: return None @@ -404,18 +389,18 @@ class ActionsModel: if not selection.is_project_selected: return [] - context = self._get_webaction_context(selection) + request_data = self._get_webaction_request_data(selection) project_name = selection.project_name entity_id = None - if context["entityIds"]: - entity_id = context["entityIds"][0] + if request_data["entityIds"]: + entity_id = request_data["entityIds"][0] cache: CacheItem = self._webaction_items[project_name][entity_id] if cache.is_valid: return cache.get_data() try: - response = ayon_api.post("actions/list", **context) + response = ayon_api.post("actions/list", **request_data) response.raise_for_status() except Exception: self.log.warning("Failed to collect webactions.", exc_info=True) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index fca27e3b6e..e9447d1ccb 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -16,6 +16,7 @@ from ayon_core.lib.attribute_definitions import ( from ayon_core.tools.flickcharm import FlickCharm from ayon_core.tools.utils import get_qt_icon from ayon_core.tools.attribute_defs import AttributeDefinitionsDialog +from ayon_core.tools.launcher.abstract import WebactionContext from .resources import get_options_image_path @@ -428,13 +429,15 @@ class ActionsWidget(QtWidgets.QWidget): return form_data = dialog.get_values() self._controller.trigger_webaction( - identifier, - event["project_name"], - event["folder_id"], - event["task_id"], + WebactionContext( + identifier, + event["project_name"], + event["folder_id"], + event["task_id"], + event["addon_name"], + event["addon_version"], + ), event["action_label"], - event["addon_name"], - event["addon_version"], form_data, ) @@ -502,12 +505,20 @@ class ActionsWidget(QtWidgets.QWidget): addon_name = index.data(ACTION_ADDON_NAME_ROLE) addon_version = index.data(ACTION_ADDON_VERSION_ROLE) - args = [action_id, project_name, folder_id, task_id] if action_type == "webaction": - args.extend([action_label, addon_name, addon_version]) - self._controller.trigger_webaction(*args) + context = WebactionContext( + action_id, + project_name, + folder_id, + task_id, + addon_name, + addon_version + ) + self._controller.trigger_webaction(context, action_label) else: - self._controller.trigger_action(*args) + self._controller.trigger_action( + action_id, project_name, folder_id, task_id + ) self._start_animation(index) @@ -547,7 +558,7 @@ class ActionsWidget(QtWidgets.QWidget): task_id = self._model.get_selected_task_id() addon_name = index.data(ACTION_ADDON_NAME_ROLE) addon_version = index.data(ACTION_ADDON_VERSION_ROLE) - values = self._controller.get_action_config_values( + context = WebactionContext( action_id, project_name=project_name, folder_id=folder_id, @@ -555,6 +566,7 @@ class ActionsWidget(QtWidgets.QWidget): addon_name=addon_name, addon_version=addon_version, ) + values = self._controller.get_action_config_values(context) dialog = self._create_attrs_dialog( config_fields, @@ -567,15 +579,7 @@ class ActionsWidget(QtWidgets.QWidget): if result != QtWidgets.QDialog.Accepted: return new_values = dialog.get_values() - self._controller.set_action_config_values( - action_id, - project_name=project_name, - folder_id=folder_id, - task_id=task_id, - addon_name=addon_name, - addon_version=addon_version, - values=new_values, - ) + self._controller.set_action_config_values(context, new_values) def _create_attrs_dialog( self, From 2cf4b0d119a450695be604139a6cb19b394f17bf Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 22 May 2025 17:12:09 +0200 Subject: [PATCH 067/103] Use more generic except --- client/ayon_core/tools/utils/widgets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/utils/widgets.py b/client/ayon_core/tools/utils/widgets.py index aea2eca6a2..b842d3a4b8 100644 --- a/client/ayon_core/tools/utils/widgets.py +++ b/client/ayon_core/tools/utils/widgets.py @@ -8,7 +8,7 @@ import qtawesome try: import markdown -except (ImportError, SyntaxError, TypeError): +except Exception: markdown = None from ayon_core.style import ( From cc7668d32b1f185dedf9f85961ead86d6dfc3c37 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 23 May 2025 09:25:02 +0200 Subject: [PATCH 068/103] Set config values on all items in group --- client/ayon_core/tools/launcher/ui/actions_widget.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index e9447d1ccb..c9c6e87f50 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -553,6 +553,8 @@ class ActionsWidget(QtWidgets.QWidget): if not config_fields: return + is_group = index.data(ACTION_IS_GROUP_ROLE) + project_name = self._model.get_selected_project_name() folder_id = self._model.get_selected_folder_id() task_id = self._model.get_selected_task_id() @@ -579,7 +581,15 @@ class ActionsWidget(QtWidgets.QWidget): if result != QtWidgets.QDialog.Accepted: return new_values = dialog.get_values() - self._controller.set_action_config_values(context, new_values) + if is_group: + action_items = self._model.get_group_items(action_id) + action_ids = [item.identifier for item in action_items] + else: + action_ids = [action_id] + + for action_id in action_ids: + context.identifier = action_id + self._controller.set_action_config_values(context, new_values) def _create_attrs_dialog( self, From a4fd1e19af6db77142918404a307722d19728141 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 23 May 2025 16:52:20 +0200 Subject: [PATCH 069/103] add settings icon to each action --- client/ayon_core/style/style.css | 16 +++ .../tools/launcher/ui/actions_widget.py | 131 ++++++++++++++++-- 2 files changed, 139 insertions(+), 8 deletions(-) diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index 0e19702d53..70ab552f75 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -829,6 +829,22 @@ HintedLineEditButton { } /* Launcher specific stylesheets */ +ActionVariantWidget { + background: transparent; +} + +ActionVariantWidget[state="hover"], #OptionalActionOption[state="hover"] { + background: {color:bg-view-hover}; +} + +LauncherSettingsButton { + background: transparent; +} + +LauncherSettingsButton:hover { + border: 1px solid #f4f5f5; +} + #IconView[mode="icon"] { /* font size can't be set on items */ font-size: 9pt; diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index c9c6e87f50..e79dd0d74b 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -14,7 +14,7 @@ from ayon_core.lib.attribute_definitions import ( HiddenDef, ) from ayon_core.tools.flickcharm import FlickCharm -from ayon_core.tools.utils import get_qt_icon +from ayon_core.tools.utils import get_qt_icon, SquareButton from ayon_core.tools.attribute_defs import AttributeDefinitionsDialog from ayon_core.tools.launcher.abstract import WebactionContext @@ -47,6 +47,93 @@ def _variant_label_sort_getter(action_item): return action_item.variant_label or "" +# --- Replacement for QAction for action variants --- +class LauncherSettingsButton(SquareButton): + _settings_icon = None + + def __init__(self, parent): + super().__init__(parent) + self.setIcon(self._get_settings_icon()) + + @classmethod + def _get_settings_icon(cls): + if cls._settings_icon is None: + cls._settings_icon = get_qt_icon({ + "type": "material-symbols", + "name": "settings", + }) + return cls._settings_icon + + +class ActionVariantWidget(QtWidgets.QFrame): + settings_requested = QtCore.Signal(str) + + def __init__(self, item_id, label, has_settings, parent): + super().__init__(parent) + + self.setMouseTracking(True) + + label_widget = QtWidgets.QLabel(label, self) + settings_btn = None + if has_settings: + settings_btn = LauncherSettingsButton(self) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(6, 4, 4, 4) + layout.setSpacing(0) + layout.addWidget(label_widget, 1) + if settings_btn is not None: + layout.addSpacing(6) + layout.addWidget(settings_btn, 0) + + settings_btn.clicked.connect(self._on_settings_clicked) + + self._item_id = item_id + self._label_widget = label_widget + self._settings_btn = settings_btn + + def enterEvent(self, event): + """Handle mouse enter event.""" + self._set_hover_properties(True) + super().enterEvent(event) + + def leaveEvent(self, event): + """Handle mouse enter event.""" + self._set_hover_properties(False) + self.setProperty("state", "") + self.style().polish(self) + super().leaveEvent(event) + + def _on_settings_clicked(self): + self.settings_requested.emit(self._item_id) + + def _set_hover_properties(self, hovered): + state = "hover" if hovered else "" + if self.property("state") != state: + self.setProperty("state", state) + self.style().polish(self) + + +class ActionVariantAction(QtWidgets.QWidgetAction): + """Menu action with settings button.""" + settings_requested = QtCore.Signal(str) + + def __init__(self, item_id, label, has_settings, parent): + super().__init__(parent) + self._item_id = item_id + self._label = label + self._has_settings = has_settings + self._widget = None + + def createWidget(self, parent): + widget = ActionVariantWidget( + self._item_id, self._label, self._has_settings, parent + ) + widget.settings_requested.connect(self.settings_requested) + self._widget = widget + return widget + + class ActionsQtModel(QtGui.QStandardItemModel): """Qt model for actions. @@ -122,8 +209,10 @@ class ActionsQtModel(QtGui.QStandardItemModel): root_item = self.invisibleRootItem() all_action_items_info = [] + action_items_by_id = {} items_by_label = collections.defaultdict(list) for item in items: + action_items_by_id[item.identifier] = item if not item.variant_label: all_action_items_info.append((item, False)) else: @@ -139,7 +228,6 @@ class ActionsQtModel(QtGui.QStandardItemModel): transparent_icon = {"type": "transparent", "size": 256} new_items = [] items_by_id = {} - action_items_by_id = {} for action_item_info in all_action_items_info: action_item, is_group = action_item_info icon_def = action_item.icon @@ -175,7 +263,6 @@ class ActionsQtModel(QtGui.QStandardItemModel): item.setData(action_item.addon_version, ACTION_ADDON_VERSION_ROLE) item.setData(action_item.order, ACTION_SORT_ROLE) items_by_id[action_item.identifier] = item - action_items_by_id[action_item.identifier] = action_item if new_items: root_item.appendRows(new_items) @@ -528,8 +615,31 @@ class ActionsWidget(QtWidgets.QWidget): menu = QtWidgets.QMenu(self) actions_mapping = {} + def on_settings_clicked(identifier): + # Close menu + menu.close() + + # Show config dialog + action_item = next( + item + for item in action_items + if item.identifier == identifier + ) + self._show_config_dialog( + identifier, + False, + action_item.addon_name, + action_item.addon_version, + ) + for action_item in action_items: - menu_action = QtWidgets.QAction(action_item.full_label) + menu_action = ActionVariantAction( + action_item.identifier, + action_item.full_label, + bool(action_item.config_fields), + menu, + ) + menu_action.settings_requested.connect(on_settings_clicked) menu.addAction(menu_action) actions_mapping[menu_action] = action_item @@ -548,18 +658,23 @@ class ActionsWidget(QtWidgets.QWidget): action_id = index.data(ACTION_ID_ROLE) if not action_id: return + is_group = index.data(ACTION_IS_GROUP_ROLE) + addon_name = index.data(ACTION_ADDON_NAME_ROLE) + addon_version = index.data(ACTION_ADDON_VERSION_ROLE) + self._show_config_dialog( + action_id, is_group, addon_name, addon_version + ) + def _show_config_dialog( + self, action_id, is_group, addon_name, addon_version + ): config_fields = self._model.get_action_config_fields(action_id) if not config_fields: return - is_group = index.data(ACTION_IS_GROUP_ROLE) - project_name = self._model.get_selected_project_name() folder_id = self._model.get_selected_folder_id() task_id = self._model.get_selected_task_id() - addon_name = index.data(ACTION_ADDON_NAME_ROLE) - addon_version = index.data(ACTION_ADDON_VERSION_ROLE) context = WebactionContext( action_id, project_name=project_name, From 6f40c92f808455275db651bdf9d7913d6a4583c9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 26 May 2025 10:51:52 +0200 Subject: [PATCH 070/103] fix style issues --- client/ayon_core/tools/launcher/ui/actions_widget.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index e79dd0d74b..e22b810b8d 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -92,6 +92,11 @@ class ActionVariantWidget(QtWidgets.QFrame): self._label_widget = label_widget self._settings_btn = settings_btn + def showEvent(self, event): + super().showEvent(event) + # Make sure to set up current state + self._set_hover_properties(self.underMouse()) + def enterEvent(self, event): """Handle mouse enter event.""" self._set_hover_properties(True) @@ -100,8 +105,6 @@ class ActionVariantWidget(QtWidgets.QFrame): def leaveEvent(self, event): """Handle mouse enter event.""" self._set_hover_properties(False) - self.setProperty("state", "") - self.style().polish(self) super().leaveEvent(event) def _on_settings_clicked(self): From 60f6e0cec059f320b831c944b30dd60a4442bd82 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 26 May 2025 10:52:48 +0200 Subject: [PATCH 071/103] simplified config dialog method --- .../tools/launcher/ui/actions_widget.py | 28 ++++++------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index e22b810b8d..8fb2b3e60a 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -382,7 +382,7 @@ class ActionDelegate(QtWidgets.QStyledItemDelegate): if index.data(ANIMATION_STATE_ROLE): self._draw_animation(painter, option, index) - super(ActionDelegate, self).paint(painter, option, index) + super().paint(painter, option, index) if not index.data(ACTION_IS_GROUP_ROLE): return @@ -623,17 +623,7 @@ class ActionsWidget(QtWidgets.QWidget): menu.close() # Show config dialog - action_item = next( - item - for item in action_items - if item.identifier == identifier - ) - self._show_config_dialog( - identifier, - False, - action_item.addon_name, - action_item.addon_version, - ) + self._show_config_dialog(identifier, False) for action_item in action_items: menu_action = ActionVariantAction( @@ -662,19 +652,17 @@ class ActionsWidget(QtWidgets.QWidget): if not action_id: return is_group = index.data(ACTION_IS_GROUP_ROLE) - addon_name = index.data(ACTION_ADDON_NAME_ROLE) - addon_version = index.data(ACTION_ADDON_VERSION_ROLE) - self._show_config_dialog( - action_id, is_group, addon_name, addon_version - ) + self._show_config_dialog(action_id, is_group) - def _show_config_dialog( - self, action_id, is_group, addon_name, addon_version - ): + def _show_config_dialog(self, action_id, is_group): + item = self._model.get_item_by_id(action_id) config_fields = self._model.get_action_config_fields(action_id) if not config_fields: return + addon_name = item.data(ACTION_ADDON_NAME_ROLE) + addon_version = item.data(ACTION_ADDON_VERSION_ROLE) + project_name = self._model.get_selected_project_name() folder_id = self._model.get_selected_folder_id() task_id = self._model.get_selected_task_id() From 9787f4ad88cfb2fa215154e53a0c79187b43f9bc Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 26 May 2025 12:23:03 +0200 Subject: [PATCH 072/103] fix number def creation --- client/ayon_core/tools/launcher/ui/actions_widget.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 8fb2b3e60a..25789054e1 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -763,9 +763,9 @@ class ActionsWidget(QtWidgets.QWidget): default=value, label=config_field.get("label"), decimals=0 if field_type == "integer" else 5, - placeholder=config_field.get("placeholder"), - min_value=config_field.get("min"), - max_value=config_field.get("max"), + # placeholder=config_field.get("placeholder"), + minimum=config_field.get("min"), + maximum=config_field.get("max"), ) elif field_type in ("select", "multiselect"): attr_def = EnumDef( From 5baf624124b106d70d4f1a5d1592a28b8d3eec84 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 26 May 2025 17:55:20 +0200 Subject: [PATCH 073/103] remove mouse tracking --- client/ayon_core/tools/launcher/ui/actions_widget.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 25789054e1..57d657e5d5 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -71,7 +71,6 @@ class ActionVariantWidget(QtWidgets.QFrame): def __init__(self, item_id, label, has_settings, parent): super().__init__(parent) - self.setMouseTracking(True) label_widget = QtWidgets.QLabel(label, self) settings_btn = None From 0be94fc2d80a60e9c1cf3d8018a31e03b904cfeb Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 27 May 2025 08:59:38 +0200 Subject: [PATCH 074/103] remove unnecessary line --- client/ayon_core/tools/launcher/ui/actions_widget.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 57d657e5d5..d6338bbb73 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -71,7 +71,6 @@ class ActionVariantWidget(QtWidgets.QFrame): def __init__(self, item_id, label, has_settings, parent): super().__init__(parent) - label_widget = QtWidgets.QLabel(label, self) settings_btn = None if has_settings: From d312101573e6505fa462390ab3da29aa441c3bcf Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 27 May 2025 18:29:12 +0200 Subject: [PATCH 075/103] use tooltip widget to show subactions --- client/ayon_core/style/style.css | 5 + .../tools/launcher/ui/actions_widget.py | 364 +++++++++++++----- 2 files changed, 267 insertions(+), 102 deletions(-) diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index 70ab552f75..d75837a656 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -829,6 +829,11 @@ HintedLineEditButton { } /* Launcher specific stylesheets */ +ActionMenuToolTip { + border: 1px solid #555555; + background: {color:bg-inputs}; +} + ActionVariantWidget { background: transparent; } diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index d6338bbb73..071982e3fa 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -304,13 +304,219 @@ class ActionsQtModel(QtGui.QStandardItemModel): self.refresh() +class ActionMenuToolTip(QtWidgets.QFrame): + def __init__(self, parent): + super().__init__(parent) + + self.setWindowFlags(QtCore.Qt.ToolTip) + self.setAttribute(QtCore.Qt.WA_ShowWithoutActivating, True) + self.setAutoFillBackground(True) + self.setBackgroundRole(QtGui.QPalette.Base) + + # Update size on show + show_timer = QtCore.QTimer() + show_timer.setSingleShot(True) + show_timer.setInterval(5) + + # Close widget if is not updated by event + close_timer = QtCore.QTimer() + close_timer.setSingleShot(True) + close_timer.setInterval(100) + + update_state_timer = QtCore.QTimer() + update_state_timer.setInterval(500) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + + close_timer.timeout.connect(self.close) + show_timer.timeout.connect(self._on_show_timer) + update_state_timer.timeout.connect(self._on_update_state) + + self._main_layout = main_layout + self._show_timer = show_timer + self._close_timer = close_timer + self._update_state_timer = update_state_timer + + self._showed = False + self._mouse_entered = False + self._view_hovered = False + self._current_id = None + self._view = None + self._last_pos = QtCore.QPoint(0, 0) + self._widgets_by_id = {} + + def showEvent(self, event): + self._showed = True + self._update_state_timer.start() + super().showEvent(event) + + def closeEvent(self, event): + self._showed = False + self._update_state_timer.stop() + self._mouse_entered = False + super().closeEvent(event) + + def enterEvent(self, event): + self._mouse_entered = True + self._close_timer.stop() + super().leaveEvent(event) + + def leaveEvent(self, event): + self._mouse_entered = False + super().leaveEvent(event) + if not self._view_hovered: + self._close_timer.start() + + def mouse_entered_view(self): + self._view_hovered = True + + def mouse_left_view(self): + self._view_hovered = False + if not self._mouse_entered: + self._close_timer.start() + + def show_on_event(self, action_id, action_items, view, event): + self._close_timer.stop() + + self._view_hovered = True + + is_current = action_id == self._current_id + if not is_current: + self._current_id = action_id + self._view = view + self._update_items(view, action_items) + + # Nothing to show + if not self._widgets_by_id: + if self._showed: + self.close() + return + + # Make sure is visible + update_position = not is_current + if not self._showed: + update_position = True + self.show() + + self._last_pos = QtCore.QPoint(event.globalPos()) + if not update_position: + # Only resize if is current + self.resize(self.sizeHint()) + else: + # Set geometry to position + # - first make sure widget changes from '_update_items' + # are recalculated + app = QtWidgets.QApplication.instance() + app.processEvents() + self._on_update_state() + + self.raise_() + self._show_timer.start() + + def _on_show_timer(self): + size = self.sizeHint() + self.resize(size) + + def _on_update_state(self): + if not self._view_hovered: + return + size = self.sizeHint() + pos = self._last_pos + offset = 4 + self.setGeometry( + pos.x() + offset, pos.y() + offset, + size.width(), size.height() + ) + + def _update_items(self, view, action_items): + """Update items in the tooltip.""" + # This method can be used to update the content of the tooltip + # with new icon, text and settings button visibility. + + remove_ids = set(self._widgets_by_id.keys()) + new_ids = set() + widgets = [] + + any_has_settings = False + prepared_items = [] + for idx, action_item in enumerate(action_items): + has_settings = bool(action_item.config_fields) + if has_settings: + any_has_settings = True + prepared_items.append((idx, action_item, has_settings)) + + if any_has_settings or len(action_items) > 1: + for idx, action_item, has_settings in prepared_items: + widget = self._widgets_by_id.get(action_item.identifier) + icon = get_qt_icon(action_item.icon) + label = action_item.full_label + if widget is None: + widget = ActionVariantWidget( + action_item.identifier, label, has_settings, self + ) + widget.settings_requested.connect( + view.settings_requested + ) + new_ids.add(action_item.identifier) + self._widgets_by_id[action_item.identifier] = widget + else: + remove_ids.discard(action_item.identifier) + widgets.append((idx, widget)) + + for action_id in remove_ids: + widget = self._widgets_by_id.pop(action_id) + widget.setVisible(False) + self._main_layout.removeWidget(widget) + widget.deleteLater() + + for idx, widget in widgets: + self._main_layout.insertWidget(idx, widget, 0) + + class ActionDelegate(QtWidgets.QStyledItemDelegate): _cached_extender = {} def __init__(self, *args, **kwargs): - super(ActionDelegate, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self._anim_start_color = QtGui.QColor(178, 255, 246) self._anim_end_color = QtGui.QColor(5, 44, 50) + self._tooltip_widget = None + + def helpEvent(self, event, view, option, index): + if not index.isValid(): + if self._tooltip_widget is not None: + self._tooltip_widget.close() + return False + + action_id = index.data(ACTION_ID_ROLE) + model = index.model() + source_model = model.sourceModel() + if index.data(ACTION_IS_GROUP_ROLE): + action_items = source_model.get_group_items(action_id) + else: + action_items = [source_model.get_action_item_by_id(action_id)] + if self._tooltip_widget is None: + self._tooltip_widget = ActionMenuToolTip(view) + + self._tooltip_widget.show_on_event( + action_id, action_items, view, event + ) + event.setAccepted(True) + return True + + def close_tooltip(self): + if self._tooltip_widget is not None: + self._tooltip_widget.close() + + def mouse_entered_view(self): + if self._tooltip_widget is not None: + self._tooltip_widget.mouse_entered_view() + + def mouse_left_view(self): + if self._tooltip_widget is not None: + self._tooltip_widget.mouse_left_view() def _draw_animation(self, painter, option, index): grid_size = option.widget.gridSize() @@ -430,29 +636,51 @@ class ActionsProxyModel(QtCore.QSortFilterProxyModel): return True +class ActionsView(QtWidgets.QListView): + settings_requested = QtCore.Signal(str) + + def __init__(self, parent): + super().__init__(parent) + self.setProperty("mode", "icon") + self.setObjectName("IconView") + self.setViewMode(QtWidgets.QListView.IconMode) + self.setResizeMode(QtWidgets.QListView.Adjust) + self.setSelectionMode(QtWidgets.QListView.NoSelection) + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) + self.setWrapping(True) + self.setGridSize(QtCore.QSize(70, 75)) + self.setIconSize(QtCore.QSize(30, 30)) + self.setSpacing(0) + self.setWordWrap(True) + self.setToolTipDuration(150) + + delegate = ActionDelegate(self) + self.setItemDelegate(delegate) + + # Make view flickable + flick = FlickCharm(parent=self) + flick.activateOn(self) + + self._flick = flick + self._delegate = delegate + + def enterEvent(self, event): + super().enterEvent(event) + self._delegate.mouse_entered_view() + + def leaveEvent(self, event): + super().leaveEvent(event) + self._delegate.mouse_left_view() + + class ActionsWidget(QtWidgets.QWidget): def __init__(self, controller, parent): - super(ActionsWidget, self).__init__(parent) + super().__init__(parent) self._controller = controller - view = QtWidgets.QListView(self) - view.setProperty("mode", "icon") - view.setObjectName("IconView") - view.setViewMode(QtWidgets.QListView.IconMode) - view.setResizeMode(QtWidgets.QListView.Adjust) - view.setSelectionMode(QtWidgets.QListView.NoSelection) - view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) - view.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) - view.setWrapping(True) - view.setGridSize(QtCore.QSize(70, 75)) - view.setIconSize(QtCore.QSize(30, 30)) - view.setSpacing(0) - view.setWordWrap(True) - - # Make view flickable - flick = FlickCharm(parent=view) - flick.activateOn(view) + view = ActionsView(self) model = ActionsQtModel(controller) @@ -460,9 +688,6 @@ class ActionsWidget(QtWidgets.QWidget): proxy_model.setSourceModel(model) view.setModel(proxy_model) - delegate = ActionDelegate(self) - view.setItemDelegate(delegate) - layout = QtWidgets.QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(view) @@ -472,13 +697,12 @@ class ActionsWidget(QtWidgets.QWidget): animation_timer.timeout.connect(self._on_animation) view.clicked.connect(self._on_clicked) - view.customContextMenuRequested.connect(self._on_context_menu) + view.settings_requested.connect(self._show_config_dialog) model.refreshed.connect(self._on_model_refresh) self._animated_items = set() self._animation_timer = animation_timer - self._flick = flick self._view = view self._model = model self._proxy_model = proxy_model @@ -572,37 +796,27 @@ class ActionsWidget(QtWidgets.QWidget): return is_group = index.data(ACTION_IS_GROUP_ROLE) + # TODO define and store what is default action for a group action_id = index.data(ACTION_ID_ROLE) project_name = self._model.get_selected_project_name() folder_id = self._model.get_selected_folder_id() task_id = self._model.get_selected_task_id() - if is_group: - action_item = self._show_menu_on_group(action_id) - if action_item is None: - return - - action_id = action_item.identifier - action_label = action_item.full_label - action_type = action_item.action_type - addon_name = action_item.addon_name - addon_version = action_item.addon_version - else: - action_label = index.data(QtCore.Qt.DisplayRole) - action_type = index.data(ACTION_TYPE_ROLE) - addon_name = index.data(ACTION_ADDON_NAME_ROLE) - addon_version = index.data(ACTION_ADDON_VERSION_ROLE) + action_type = index.data(ACTION_TYPE_ROLE) if action_type == "webaction": + action_item = self._model.get_action_item_by_id(action_id) context = WebactionContext( action_id, project_name, folder_id, task_id, - addon_name, - addon_version + action_item.addon_name, + action_item.add_version + ) + self._controller.trigger_webaction( + context, action_item.full_label ) - self._controller.trigger_webaction(context, action_label) else: self._controller.trigger_action( action_id, project_name, folder_id, task_id @@ -610,57 +824,12 @@ class ActionsWidget(QtWidgets.QWidget): self._start_animation(index) - def _show_menu_on_group(self, action_id): - action_items = self._model.get_group_items(action_id) - - menu = QtWidgets.QMenu(self) - actions_mapping = {} - - def on_settings_clicked(identifier): - # Close menu - menu.close() - - # Show config dialog - self._show_config_dialog(identifier, False) - - for action_item in action_items: - menu_action = ActionVariantAction( - action_item.identifier, - action_item.full_label, - bool(action_item.config_fields), - menu, - ) - menu_action.settings_requested.connect(on_settings_clicked) - menu.addAction(menu_action) - actions_mapping[menu_action] = action_item - - result = menu.exec_(QtGui.QCursor.pos()) - if not result: - return None - - return actions_mapping[result] - - def _on_context_menu(self, point): - """Creates menu to force skip opening last workfile.""" - index = self._view.indexAt(point) - if not index.isValid(): - return - - action_id = index.data(ACTION_ID_ROLE) - if not action_id: - return - is_group = index.data(ACTION_IS_GROUP_ROLE) - self._show_config_dialog(action_id, is_group) - - def _show_config_dialog(self, action_id, is_group): - item = self._model.get_item_by_id(action_id) + def _show_config_dialog(self, action_id): + action_item = self._model.get_action_item_by_id(action_id) config_fields = self._model.get_action_config_fields(action_id) if not config_fields: return - addon_name = item.data(ACTION_ADDON_NAME_ROLE) - addon_version = item.data(ACTION_ADDON_VERSION_ROLE) - project_name = self._model.get_selected_project_name() folder_id = self._model.get_selected_folder_id() task_id = self._model.get_selected_task_id() @@ -669,8 +838,8 @@ class ActionsWidget(QtWidgets.QWidget): project_name=project_name, folder_id=folder_id, task_id=task_id, - addon_name=addon_name, - addon_version=addon_version, + addon_name=action_item.addon_name, + addon_version=action_item.addon_version, ) values = self._controller.get_action_config_values(context) @@ -682,17 +851,8 @@ class ActionsWidget(QtWidgets.QWidget): ) dialog.set_values(values) result = dialog.exec_() - if result != QtWidgets.QDialog.Accepted: - return - new_values = dialog.get_values() - if is_group: - action_items = self._model.get_group_items(action_id) - action_ids = [item.identifier for item in action_items] - else: - action_ids = [action_id] - - for action_id in action_ids: - context.identifier = action_id + if result == QtWidgets.QDialog.Accepted: + new_values = dialog.get_values() self._controller.set_action_config_values(context, new_values) def _create_attrs_dialog( From 9416d8b1f98be3fcab730b00501f01fb7aefeff7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 28 May 2025 12:02:39 +0200 Subject: [PATCH 076/103] fix typo --- client/ayon_core/tools/launcher/ui/actions_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 071982e3fa..03906d3267 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -812,7 +812,7 @@ class ActionsWidget(QtWidgets.QWidget): folder_id, task_id, action_item.addon_name, - action_item.add_version + action_item.addon_version ) self._controller.trigger_webaction( context, action_item.full_label From 720e161deb931dc2ef2721e89e1b96718e7a6fc6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 28 May 2025 12:25:55 +0200 Subject: [PATCH 077/103] add clickable label to the shown menu --- .../tools/launcher/ui/actions_widget.py | 49 ++++++++++++++++--- client/ayon_core/tools/utils/widgets.py | 8 +-- 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 03906d3267..15547db03f 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -14,7 +14,7 @@ from ayon_core.lib.attribute_definitions import ( HiddenDef, ) from ayon_core.tools.flickcharm import FlickCharm -from ayon_core.tools.utils import get_qt_icon, SquareButton +from ayon_core.tools.utils import get_qt_icon, SquareButton, ClickableLabel from ayon_core.tools.attribute_defs import AttributeDefinitionsDialog from ayon_core.tools.launcher.abstract import WebactionContext @@ -66,12 +66,13 @@ class LauncherSettingsButton(SquareButton): class ActionVariantWidget(QtWidgets.QFrame): + action_triggered = QtCore.Signal(str) settings_requested = QtCore.Signal(str) def __init__(self, item_id, label, has_settings, parent): super().__init__(parent) - label_widget = QtWidgets.QLabel(label, self) + label_widget = ClickableLabel(label, self) settings_btn = None if has_settings: settings_btn = LauncherSettingsButton(self) @@ -85,6 +86,7 @@ class ActionVariantWidget(QtWidgets.QFrame): layout.addWidget(settings_btn, 0) settings_btn.clicked.connect(self._on_settings_clicked) + label_widget.clicked.connect(self._on_trigger) self._item_id = item_id self._label_widget = label_widget @@ -105,6 +107,9 @@ class ActionVariantWidget(QtWidgets.QFrame): self._set_hover_properties(False) super().leaveEvent(event) + def _on_trigger(self): + self.action_triggered.emit(self._item_id) + def _on_settings_clicked(self): self.settings_requested.emit(self._item_id) @@ -186,6 +191,18 @@ class ActionsQtModel(QtGui.QStandardItemModel): def get_item_by_id(self, action_id): return self._items_by_id.get(action_id) + def get_group_item_by_action_id(self, action_id): + item = self._items_by_id.get(action_id) + if item is not None: + return item + + for group_id, items in self._groups_by_id.items(): + for item in items: + if item.identifier == action_id: + return self._items_by_id[group_id] + + return None + def get_action_item_by_id(self, action_id): return self._action_items_by_id.get(action_id) @@ -456,8 +473,9 @@ class ActionMenuToolTip(QtWidgets.QFrame): widget = ActionVariantWidget( action_item.identifier, label, has_settings, self ) + widget.action_triggered.connect(self._on_trigger) widget.settings_requested.connect( - view.settings_requested + self._on_settings_trigger ) new_ids.add(action_item.identifier) self._widgets_by_id[action_item.identifier] = widget @@ -474,6 +492,15 @@ class ActionMenuToolTip(QtWidgets.QFrame): for idx, widget in widgets: self._main_layout.insertWidget(idx, widget, 0) + def _on_trigger(self, action_id): + self.close() + self._view.action_triggered.emit(action_id) + + def _on_settings_trigger(self, action_id): + """Handle settings button click.""" + self.close() + self._view.settings_requested.emit(action_id) + class ActionDelegate(QtWidgets.QStyledItemDelegate): _cached_extender = {} @@ -637,6 +664,7 @@ class ActionsProxyModel(QtCore.QSortFilterProxyModel): class ActionsView(QtWidgets.QListView): + action_triggered = QtCore.Signal(str) settings_requested = QtCore.Signal(str) def __init__(self, parent): @@ -697,6 +725,7 @@ class ActionsWidget(QtWidgets.QWidget): animation_timer.timeout.connect(self._on_animation) view.clicked.connect(self._on_clicked) + view.action_triggered.connect(self._trigger_action) view.settings_requested.connect(self._show_config_dialog) model.refreshed.connect(self._on_model_refresh) @@ -798,13 +827,15 @@ class ActionsWidget(QtWidgets.QWidget): is_group = index.data(ACTION_IS_GROUP_ROLE) # TODO define and store what is default action for a group action_id = index.data(ACTION_ID_ROLE) + self._trigger_action(action_id, index) + def _trigger_action(self, action_id, index=None): project_name = self._model.get_selected_project_name() folder_id = self._model.get_selected_folder_id() task_id = self._model.get_selected_task_id() + action_item = self._model.get_action_item_by_id(action_id) - action_type = index.data(ACTION_TYPE_ROLE) - if action_type == "webaction": + if action_item.action_type == "webaction": action_item = self._model.get_action_item_by_id(action_id) context = WebactionContext( action_id, @@ -822,7 +853,13 @@ class ActionsWidget(QtWidgets.QWidget): action_id, project_name, folder_id, task_id ) - self._start_animation(index) + if index is None: + item = self._model.get_group_item_by_action_id(action_id) + if item is not None: + index = self._proxy_model.mapFromSource(item.index()) + + if index is not None: + self._start_animation(index) def _show_config_dialog(self, action_id): action_item = self._model.get_action_item_by_id(action_id) diff --git a/client/ayon_core/tools/utils/widgets.py b/client/ayon_core/tools/utils/widgets.py index b842d3a4b8..234b8a5566 100644 --- a/client/ayon_core/tools/utils/widgets.py +++ b/client/ayon_core/tools/utils/widgets.py @@ -495,15 +495,15 @@ class ClickableLabel(QtWidgets.QLabel): """Label that catch left mouse click and can trigger 'clicked' signal.""" clicked = QtCore.Signal() - def __init__(self, parent): - super(ClickableLabel, self).__init__(parent) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) self._mouse_pressed = False def mousePressEvent(self, event): if event.button() == QtCore.Qt.LeftButton: self._mouse_pressed = True - super(ClickableLabel, self).mousePressEvent(event) + super().mousePressEvent(event) def mouseReleaseEvent(self, event): if self._mouse_pressed: @@ -511,7 +511,7 @@ class ClickableLabel(QtWidgets.QLabel): if self.rect().contains(event.pos()): self.clicked.emit() - super(ClickableLabel, self).mouseReleaseEvent(event) + super().mouseReleaseEvent(event) class ExpandBtnLabel(QtWidgets.QLabel): From f7c072ce2a339deeedd5d002c5eb17c1b63fbec7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 28 May 2025 13:17:41 +0200 Subject: [PATCH 078/103] show action icon in menu --- .../tools/launcher/ui/actions_widget.py | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 15547db03f..3760d8b470 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -14,7 +14,12 @@ from ayon_core.lib.attribute_definitions import ( HiddenDef, ) from ayon_core.tools.flickcharm import FlickCharm -from ayon_core.tools.utils import get_qt_icon, SquareButton, ClickableLabel +from ayon_core.tools.utils import ( + get_qt_icon, + SquareButton, + ClickableLabel, + PixmapLabel, +) from ayon_core.tools.attribute_defs import AttributeDefinitionsDialog from ayon_core.tools.launcher.abstract import WebactionContext @@ -69,9 +74,13 @@ class ActionVariantWidget(QtWidgets.QFrame): action_triggered = QtCore.Signal(str) settings_requested = QtCore.Signal(str) - def __init__(self, item_id, label, has_settings, parent): + def __init__(self, item_id, icon, label, has_settings, parent): super().__init__(parent) + icon_widget = None + if icon: + icon_widget = PixmapLabel(icon.pixmap(512, 512), self) + label_widget = ClickableLabel(label, self) settings_btn = None if has_settings: @@ -80,6 +89,10 @@ class ActionVariantWidget(QtWidgets.QFrame): layout = QtWidgets.QHBoxLayout(self) layout.setContentsMargins(6, 4, 4, 4) layout.setSpacing(0) + if icon_widget is not None: + layout.addWidget(icon_widget, 0) + layout.addSpacing(6) + layout.addWidget(label_widget, 1) if settings_btn is not None: layout.addSpacing(6) @@ -89,6 +102,7 @@ class ActionVariantWidget(QtWidgets.QFrame): label_widget.clicked.connect(self._on_trigger) self._item_id = item_id + self._icon_widget = icon_widget self._label_widget = label_widget self._settings_btn = settings_btn @@ -471,7 +485,11 @@ class ActionMenuToolTip(QtWidgets.QFrame): label = action_item.full_label if widget is None: widget = ActionVariantWidget( - action_item.identifier, label, has_settings, self + action_item.identifier, + icon, + label, + has_settings, + self ) widget.action_triggered.connect(self._on_trigger) widget.settings_requested.connect( From 74bdd09a165e1f4ed8073d7d7fa9d4479c54cdfe Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 28 May 2025 13:20:46 +0200 Subject: [PATCH 079/103] fix ruff issue --- client/ayon_core/tools/launcher/ui/actions_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 3760d8b470..70d49d36c0 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -842,7 +842,7 @@ class ActionsWidget(QtWidgets.QWidget): if not index or not index.isValid(): return - is_group = index.data(ACTION_IS_GROUP_ROLE) + _is_group = index.data(ACTION_IS_GROUP_ROLE) # TODO define and store what is default action for a group action_id = index.data(ACTION_ID_ROLE) self._trigger_action(action_id, index) From ae88357f6e332b6d5cc8480390593a174c07fad6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 28 May 2025 13:51:28 +0200 Subject: [PATCH 080/103] :sparkles: add product base compatibility check function simple function to handle transitional period until product base types are implemented and current use of product types is switched completely. --- client/ayon_core/pipeline/compatibility.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 client/ayon_core/pipeline/compatibility.py diff --git a/client/ayon_core/pipeline/compatibility.py b/client/ayon_core/pipeline/compatibility.py new file mode 100644 index 0000000000..de8c8f39a6 --- /dev/null +++ b/client/ayon_core/pipeline/compatibility.py @@ -0,0 +1,16 @@ +"""Package to handle compatibility checks for pipeline components.""" + + +def is_supporting_product_base_type() -> bool: + """Check support for product base types. + + This function checks if the current pipeline supports product base types. + Once this feature is implemented, it will return True. This should be used + in places where some kind of backward compatibility is needed to avoid + breaking existing functionality that relies on the current behavior. + + Returns: + bool: True if product base types are supported, False otherwise. + + """ + return False From 5cf146ee86303c36a5b32c800f81e6d8028de589 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 28 May 2025 14:43:47 +0200 Subject: [PATCH 081/103] show the popup at the moment of hover --- client/ayon_core/style/style.css | 10 +- .../tools/launcher/ui/actions_widget.py | 271 +++++++++--------- 2 files changed, 147 insertions(+), 134 deletions(-) diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index d75837a656..979365f874 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -843,10 +843,18 @@ ActionVariantWidget[state="hover"], #OptionalActionOption[state="hover"] { } LauncherSettingsButton { + icon-size: 15px; background: transparent; + border: 1px solid #f4f5f5; + padding: 1px 3px 1px 3px; } -LauncherSettingsButton:hover { +LauncherSettingsButton[inMenu="1"] { + border: none; + padding: 3px 5px 3px 5px; +} + +LauncherSettingsButton:hover, LauncherSettingsButton[inMenu="1"]:hover { border: 1px solid #f4f5f5; } diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 70d49d36c0..3a5401f362 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -17,7 +17,7 @@ from ayon_core.tools.flickcharm import FlickCharm from ayon_core.tools.utils import ( get_qt_icon, SquareButton, - ClickableLabel, + ClickableFrame, PixmapLabel, ) from ayon_core.tools.attribute_defs import AttributeDefinitionsDialog @@ -30,11 +30,12 @@ ANIMATION_LEN = 7 ACTION_ID_ROLE = QtCore.Qt.UserRole + 1 ACTION_TYPE_ROLE = QtCore.Qt.UserRole + 2 ACTION_IS_GROUP_ROLE = QtCore.Qt.UserRole + 3 -ACTION_SORT_ROLE = QtCore.Qt.UserRole + 4 -ACTION_ADDON_NAME_ROLE = QtCore.Qt.UserRole + 5 -ACTION_ADDON_VERSION_ROLE = QtCore.Qt.UserRole + 6 -ANIMATION_START_ROLE = QtCore.Qt.UserRole + 7 -ANIMATION_STATE_ROLE = QtCore.Qt.UserRole + 8 +ACTION_HAS_CONFIGS_ROLE = QtCore.Qt.UserRole + 4 +ACTION_SORT_ROLE = QtCore.Qt.UserRole + 5 +ACTION_ADDON_NAME_ROLE = QtCore.Qt.UserRole + 6 +ACTION_ADDON_VERSION_ROLE = QtCore.Qt.UserRole + 7 +ANIMATION_START_ROLE = QtCore.Qt.UserRole + 8 +ANIMATION_STATE_ROLE = QtCore.Qt.UserRole + 9 def _variant_label_sort_getter(action_item): @@ -69,22 +70,51 @@ class LauncherSettingsButton(SquareButton): }) return cls._settings_icon + def set_is_in_menu(self, in_menu): + value = "1" if in_menu else "" + self.setProperty("inMenu", value) + self.style().polish(self) -class ActionVariantWidget(QtWidgets.QFrame): + +class ActionOverlayWidget(QtWidgets.QFrame): + config_requested = QtCore.Signal(str) + + def __init__(self, item_id, parent): + super().__init__(parent) + self._item_id = item_id + + settings_btn = LauncherSettingsButton(self) + + main_layout = QtWidgets.QGridLayout(self) + main_layout.setContentsMargins(5, 5, 0, 0) + main_layout.addWidget(settings_btn, 0, 0) + main_layout.setColumnStretch(1, 1) + main_layout.setRowStretch(1, 1) + + settings_btn.clicked.connect(self._on_settings_click) + + self.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + def _on_settings_click(self): + self.config_requested.emit(self._item_id) + + +class ActionVariantWidget(ClickableFrame): action_triggered = QtCore.Signal(str) - settings_requested = QtCore.Signal(str) + config_requested = QtCore.Signal(str) - def __init__(self, item_id, icon, label, has_settings, parent): + def __init__(self, item_id, icon, label, has_configs, parent): super().__init__(parent) icon_widget = None if icon: icon_widget = PixmapLabel(icon.pixmap(512, 512), self) - label_widget = ClickableLabel(label, self) + label_widget = QtWidgets.QLabel(label, self) settings_btn = None - if has_settings: + if has_configs: settings_btn = LauncherSettingsButton(self) + settings_btn.set_is_in_menu(True) layout = QtWidgets.QHBoxLayout(self) layout.setContentsMargins(6, 4, 4, 4) @@ -99,7 +129,7 @@ class ActionVariantWidget(QtWidgets.QFrame): layout.addWidget(settings_btn, 0) settings_btn.clicked.connect(self._on_settings_clicked) - label_widget.clicked.connect(self._on_trigger) + self.clicked.connect(self._on_trigger) self._item_id = item_id self._icon_widget = icon_widget @@ -125,7 +155,7 @@ class ActionVariantWidget(QtWidgets.QFrame): self.action_triggered.emit(self._item_id) def _on_settings_clicked(self): - self.settings_requested.emit(self._item_id) + self.config_requested.emit(self._item_id) def _set_hover_properties(self, hovered): state = "hover" if hovered else "" @@ -134,26 +164,6 @@ class ActionVariantWidget(QtWidgets.QFrame): self.style().polish(self) -class ActionVariantAction(QtWidgets.QWidgetAction): - """Menu action with settings button.""" - settings_requested = QtCore.Signal(str) - - def __init__(self, item_id, label, has_settings, parent): - super().__init__(parent) - self._item_id = item_id - self._label = label - self._has_settings = has_settings - self._widget = None - - def createWidget(self, parent): - widget = ActionVariantWidget( - self._item_id, self._label, self._has_settings, parent - ) - widget.settings_requested.connect(self.settings_requested) - self._widget = widget - return widget - - class ActionsQtModel(QtGui.QStandardItemModel): """Qt model for actions. @@ -214,7 +224,6 @@ class ActionsQtModel(QtGui.QStandardItemModel): for item in items: if item.identifier == action_id: return self._items_by_id[group_id] - return None def get_action_item_by_id(self, action_id): @@ -276,9 +285,11 @@ class ActionsQtModel(QtGui.QStandardItemModel): icon = get_qt_icon(transparent_icon.copy()) if is_group: + has_configs = False label = action_item.label else: label = action_item.full_label + has_configs = bool(action_item.config_fields) item = self._items_by_id.get(action_item.identifier) if item is None: @@ -290,6 +301,7 @@ class ActionsQtModel(QtGui.QStandardItemModel): item.setData(label, QtCore.Qt.DisplayRole) item.setData(icon, QtCore.Qt.DecorationRole) item.setData(is_group, ACTION_IS_GROUP_ROLE) + item.setData(has_configs, ACTION_HAS_CONFIGS_ROLE) item.setData(action_item.action_type, ACTION_TYPE_ROLE) item.setData(action_item.addon_name, ACTION_ADDON_NAME_ROLE) item.setData(action_item.addon_version, ACTION_ADDON_VERSION_ROLE) @@ -335,11 +347,14 @@ class ActionsQtModel(QtGui.QStandardItemModel): self.refresh() -class ActionMenuToolTip(QtWidgets.QFrame): +class ActionMenuPopup(QtWidgets.QFrame): + action_triggered = QtCore.Signal(str) + config_requested = QtCore.Signal(str) + def __init__(self, parent): super().__init__(parent) - self.setWindowFlags(QtCore.Qt.ToolTip) + self.setWindowFlags(QtCore.Qt.Popup) self.setAttribute(QtCore.Qt.WA_ShowWithoutActivating, True) self.setAutoFillBackground(True) self.setBackgroundRole(QtGui.QPalette.Base) @@ -354,38 +369,29 @@ class ActionMenuToolTip(QtWidgets.QFrame): close_timer.setSingleShot(True) close_timer.setInterval(100) - update_state_timer = QtCore.QTimer() - update_state_timer.setInterval(500) - main_layout = QtWidgets.QVBoxLayout(self) main_layout.setContentsMargins(0, 0, 0, 0) main_layout.setSpacing(0) close_timer.timeout.connect(self.close) show_timer.timeout.connect(self._on_show_timer) - update_state_timer.timeout.connect(self._on_update_state) self._main_layout = main_layout self._show_timer = show_timer self._close_timer = close_timer - self._update_state_timer = update_state_timer self._showed = False self._mouse_entered = False - self._view_hovered = False self._current_id = None - self._view = None self._last_pos = QtCore.QPoint(0, 0) self._widgets_by_id = {} def showEvent(self, event): self._showed = True - self._update_state_timer.start() super().showEvent(event) def closeEvent(self, event): self._showed = False - self._update_state_timer.stop() self._mouse_entered = False super().closeEvent(event) @@ -397,27 +403,15 @@ class ActionMenuToolTip(QtWidgets.QFrame): def leaveEvent(self, event): self._mouse_entered = False super().leaveEvent(event) - if not self._view_hovered: - self._close_timer.start() + self._close_timer.start() - def mouse_entered_view(self): - self._view_hovered = True - - def mouse_left_view(self): - self._view_hovered = False - if not self._mouse_entered: - self._close_timer.start() - - def show_on_event(self, action_id, action_items, view, event): + def show_items(self, action_id, action_items, pos): self._close_timer.stop() - self._view_hovered = True - is_current = action_id == self._current_id if not is_current: self._current_id = action_id - self._view = view - self._update_items(view, action_items) + self._update_items(action_items) # Nothing to show if not self._widgets_by_id: @@ -431,7 +425,6 @@ class ActionMenuToolTip(QtWidgets.QFrame): update_position = True self.show() - self._last_pos = QtCore.QPoint(event.globalPos()) if not update_position: # Only resize if is current self.resize(self.sizeHint()) @@ -441,7 +434,12 @@ class ActionMenuToolTip(QtWidgets.QFrame): # are recalculated app = QtWidgets.QApplication.instance() app.processEvents() - self._on_update_state() + size = self.sizeHint() + offset = 4 + self.setGeometry( + pos.x() + offset, pos.y() + offset, + size.width(), size.height() + ) self.raise_() self._show_timer.start() @@ -450,18 +448,7 @@ class ActionMenuToolTip(QtWidgets.QFrame): size = self.sizeHint() self.resize(size) - def _on_update_state(self): - if not self._view_hovered: - return - size = self.sizeHint() - pos = self._last_pos - offset = 4 - self.setGeometry( - pos.x() + offset, pos.y() + offset, - size.width(), size.height() - ) - - def _update_items(self, view, action_items): + def _update_items(self, action_items): """Update items in the tooltip.""" # This method can be used to update the content of the tooltip # with new icon, text and settings button visibility. @@ -470,16 +457,16 @@ class ActionMenuToolTip(QtWidgets.QFrame): new_ids = set() widgets = [] - any_has_settings = False + any_has_configs = False prepared_items = [] for idx, action_item in enumerate(action_items): - has_settings = bool(action_item.config_fields) - if has_settings: - any_has_settings = True - prepared_items.append((idx, action_item, has_settings)) + has_configs = bool(action_item.config_fields) + if has_configs: + any_has_configs = True + prepared_items.append((idx, action_item, has_configs)) - if any_has_settings or len(action_items) > 1: - for idx, action_item, has_settings in prepared_items: + if any_has_configs or len(action_items) > 1: + for idx, action_item, has_configs in prepared_items: widget = self._widgets_by_id.get(action_item.identifier) icon = get_qt_icon(action_item.icon) label = action_item.full_label @@ -488,12 +475,12 @@ class ActionMenuToolTip(QtWidgets.QFrame): action_item.identifier, icon, label, - has_settings, + has_configs, self ) widget.action_triggered.connect(self._on_trigger) - widget.settings_requested.connect( - self._on_settings_trigger + widget.config_requested.connect( + self._on_configs_trigger ) new_ids.add(action_item.identifier) self._widgets_by_id[action_item.identifier] = widget @@ -511,13 +498,12 @@ class ActionMenuToolTip(QtWidgets.QFrame): self._main_layout.insertWidget(idx, widget, 0) def _on_trigger(self, action_id): + self.action_triggered.emit(action_id) self.close() - self._view.action_triggered.emit(action_id) - def _on_settings_trigger(self, action_id): - """Handle settings button click.""" + def _on_configs_trigger(self, action_id): + self.config_requested.emit(action_id) self.close() - self._view.settings_requested.emit(action_id) class ActionDelegate(QtWidgets.QStyledItemDelegate): @@ -527,41 +513,12 @@ class ActionDelegate(QtWidgets.QStyledItemDelegate): super().__init__(*args, **kwargs) self._anim_start_color = QtGui.QColor(178, 255, 246) self._anim_end_color = QtGui.QColor(5, 44, 50) - self._tooltip_widget = None - def helpEvent(self, event, view, option, index): - if not index.isValid(): - if self._tooltip_widget is not None: - self._tooltip_widget.close() - return False + def sizeHint(self, option, index): + return option.widget.gridSize() - action_id = index.data(ACTION_ID_ROLE) - model = index.model() - source_model = model.sourceModel() - if index.data(ACTION_IS_GROUP_ROLE): - action_items = source_model.get_group_items(action_id) - else: - action_items = [source_model.get_action_item_by_id(action_id)] - if self._tooltip_widget is None: - self._tooltip_widget = ActionMenuToolTip(view) - - self._tooltip_widget.show_on_event( - action_id, action_items, view, event - ) - event.setAccepted(True) - return True - - def close_tooltip(self): - if self._tooltip_widget is not None: - self._tooltip_widget.close() - - def mouse_entered_view(self): - if self._tooltip_widget is not None: - self._tooltip_widget.mouse_entered_view() - - def mouse_left_view(self): - if self._tooltip_widget is not None: - self._tooltip_widget.mouse_left_view() + def updateEditorGeometry(self, editor, option, index): + editor.setGeometry(option.rect) def _draw_animation(self, painter, option, index): grid_size = option.widget.gridSize() @@ -683,7 +640,7 @@ class ActionsProxyModel(QtCore.QSortFilterProxyModel): class ActionsView(QtWidgets.QListView): action_triggered = QtCore.Signal(str) - settings_requested = QtCore.Signal(str) + config_requested = QtCore.Signal(str) def __init__(self, parent): super().__init__(parent) @@ -699,7 +656,7 @@ class ActionsView(QtWidgets.QListView): self.setIconSize(QtCore.QSize(30, 30)) self.setSpacing(0) self.setWordWrap(True) - self.setToolTipDuration(150) + self.setMouseTracking(True) delegate = ActionDelegate(self) self.setItemDelegate(delegate) @@ -708,16 +665,35 @@ class ActionsView(QtWidgets.QListView): flick = FlickCharm(parent=self) flick.activateOn(self) + popup_widget = ActionMenuPopup(self) + + popup_widget.action_triggered.connect(self.action_triggered) + popup_widget.config_requested.connect(self.config_requested) + self._flick = flick self._delegate = delegate + self._popup_widget = popup_widget - def enterEvent(self, event): - super().enterEvent(event) - self._delegate.mouse_entered_view() + def mouseMoveEvent(self, event): + """Handle mouse move event.""" + super().mouseMoveEvent(event) + # Update hover state for the item under mouse + index = self.indexAt(event.pos()) + if not index.isValid(): + return - def leaveEvent(self, event): - super().leaveEvent(event) - self._delegate.mouse_left_view() + if index.data(ACTION_IS_GROUP_ROLE): + self._show_group_popup(index) + + def _show_group_popup(self, index): + action_id = index.data(ACTION_ID_ROLE) + source_model = self.model().sourceModel() + action_items = source_model.get_group_items(action_id) + rect = self.rectForIndex(index) + pos = self.mapToGlobal(rect.topLeft()) + self._popup_widget.show_items( + action_id, action_items, pos + ) class ActionsWidget(QtWidgets.QWidget): @@ -744,7 +720,7 @@ class ActionsWidget(QtWidgets.QWidget): view.clicked.connect(self._on_clicked) view.action_triggered.connect(self._trigger_action) - view.settings_requested.connect(self._show_config_dialog) + view.config_requested.connect(self._on_config_request) model.refreshed.connect(self._on_model_refresh) self._animated_items = set() @@ -754,7 +730,7 @@ class ActionsWidget(QtWidgets.QWidget): self._model = model self._proxy_model = proxy_model - self._config_widget = None + self._overlay_widgets = [] self._set_row_height(1) @@ -808,6 +784,31 @@ class ActionsWidget(QtWidgets.QWidget): # Force repaint all items viewport = self._view.viewport() viewport.update() + self._add_overlay_widgets() + + def _add_overlay_widgets(self): + overlay_widgets = [] + viewport = self._view.viewport() + for row in range(self._proxy_model.rowCount()): + index = self._proxy_model.index(row, 0) + has_configs = index.data(ACTION_HAS_CONFIGS_ROLE) + widget = None + if has_configs: + item_id = index.data(ACTION_ID_ROLE) + widget = ActionOverlayWidget(item_id, viewport) + widget.config_requested.connect( + self._on_config_request + ) + overlay_widgets.append(widget) + self._view.setIndexWidget(index, widget) + + while self._overlay_widgets: + widget = self._overlay_widgets.pop(0) + widget.setVisible(False) + widget.setParent(None) + widget.deleteLater() + + self._overlay_widgets = overlay_widgets def _on_animation(self): time_now = time.time() @@ -842,8 +843,9 @@ class ActionsWidget(QtWidgets.QWidget): if not index or not index.isValid(): return - _is_group = index.data(ACTION_IS_GROUP_ROLE) - # TODO define and store what is default action for a group + is_group = index.data(ACTION_IS_GROUP_ROLE) + if is_group: + return action_id = index.data(ACTION_ID_ROLE) self._trigger_action(action_id, index) @@ -879,6 +881,9 @@ class ActionsWidget(QtWidgets.QWidget): if index is not None: self._start_animation(index) + def _on_config_request(self, action_id): + self._show_config_dialog(action_id) + def _show_config_dialog(self, action_id): action_item = self._model.get_action_item_by_id(action_id) config_fields = self._model.get_action_config_fields(action_id) From d082d4180bfc48fb49b05fafddf5b335b0a2e4ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 28 May 2025 15:02:10 +0200 Subject: [PATCH 082/103] :recycle: add product base type feature detection to init --- client/ayon_core/pipeline/__init__.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/client/ayon_core/pipeline/__init__.py b/client/ayon_core/pipeline/__init__.py index 41bcd0dbd1..363e8a5218 100644 --- a/client/ayon_core/pipeline/__init__.py +++ b/client/ayon_core/pipeline/__init__.py @@ -100,6 +100,10 @@ from .context_tools import ( get_current_task_name ) +from .compatibility import ( + is_supporting_product_base_type, +) + from .workfile import ( discover_workfile_build_plugins, register_workfile_build_plugin, @@ -223,4 +227,7 @@ __all__ = ( # Backwards compatible function names "install", "uninstall", + + # Feature detection + "is_supporting_product_base_type", ) From 6b7322a5d28e3789b258260c20446c3526cdc490 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 28 May 2025 17:44:36 +0200 Subject: [PATCH 083/103] change overlay expanding group --- client/ayon_core/style/style.css | 13 +- .../tools/launcher/ui/actions_widget.py | 352 +++++++++--------- 2 files changed, 176 insertions(+), 189 deletions(-) diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index 979365f874..3595733774 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -829,17 +829,8 @@ HintedLineEditButton { } /* Launcher specific stylesheets */ -ActionMenuToolTip { - border: 1px solid #555555; - background: {color:bg-inputs}; -} - -ActionVariantWidget { - background: transparent; -} - -ActionVariantWidget[state="hover"], #OptionalActionOption[state="hover"] { - background: {color:bg-view-hover}; +ActionMenuPopup { + border: 1px solid {color:border}; } LauncherSettingsButton { diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 3a5401f362..78e976937f 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -99,71 +99,6 @@ class ActionOverlayWidget(QtWidgets.QFrame): self.config_requested.emit(self._item_id) -class ActionVariantWidget(ClickableFrame): - action_triggered = QtCore.Signal(str) - config_requested = QtCore.Signal(str) - - def __init__(self, item_id, icon, label, has_configs, parent): - super().__init__(parent) - - icon_widget = None - if icon: - icon_widget = PixmapLabel(icon.pixmap(512, 512), self) - - label_widget = QtWidgets.QLabel(label, self) - settings_btn = None - if has_configs: - settings_btn = LauncherSettingsButton(self) - settings_btn.set_is_in_menu(True) - - layout = QtWidgets.QHBoxLayout(self) - layout.setContentsMargins(6, 4, 4, 4) - layout.setSpacing(0) - if icon_widget is not None: - layout.addWidget(icon_widget, 0) - layout.addSpacing(6) - - layout.addWidget(label_widget, 1) - if settings_btn is not None: - layout.addSpacing(6) - layout.addWidget(settings_btn, 0) - - settings_btn.clicked.connect(self._on_settings_clicked) - self.clicked.connect(self._on_trigger) - - self._item_id = item_id - self._icon_widget = icon_widget - self._label_widget = label_widget - self._settings_btn = settings_btn - - def showEvent(self, event): - super().showEvent(event) - # Make sure to set up current state - self._set_hover_properties(self.underMouse()) - - def enterEvent(self, event): - """Handle mouse enter event.""" - self._set_hover_properties(True) - super().enterEvent(event) - - def leaveEvent(self, event): - """Handle mouse enter event.""" - self._set_hover_properties(False) - super().leaveEvent(event) - - def _on_trigger(self): - self.action_triggered.emit(self._item_id) - - def _on_settings_clicked(self): - self.config_requested.emit(self._item_id) - - def _set_hover_properties(self, hovered): - state = "hover" if hovered else "" - if self.property("state") != state: - self.setProperty("state", state) - self.style().polish(self) - - class ActionsQtModel(QtGui.QStandardItemModel): """Qt model for actions. @@ -299,6 +234,7 @@ class ActionsQtModel(QtGui.QStandardItemModel): item.setFlags(QtCore.Qt.ItemIsEnabled) item.setData(label, QtCore.Qt.DisplayRole) + item.setData(label, QtCore.Qt.ToolTipRole) item.setData(icon, QtCore.Qt.DecorationRole) item.setData(is_group, ACTION_IS_GROUP_ROLE) item.setData(has_configs, ACTION_HAS_CONFIGS_ROLE) @@ -347,6 +283,42 @@ class ActionsQtModel(QtGui.QStandardItemModel): self.refresh() +class ActionMenuPopupModel(QtGui.QStandardItemModel): + def set_action_items(self, action_items): + """Set action items for the popup.""" + root_item = self.invisibleRootItem() + root_item.removeRows(0, root_item.rowCount()) + + transparent_icon = {"type": "transparent", "size": 256} + new_items = [] + for action_item in action_items: + icon_def = action_item.icon + if not icon_def: + icon_def = transparent_icon.copy() + + try: + icon = get_qt_icon(icon_def) + except Exception: + self._log.warning( + "Failed to parse icon definition", exc_info=True + ) + # Use empty icon if failed to parse definition + icon = get_qt_icon(transparent_icon.copy()) + + item = QtGui.QStandardItem() + item.setFlags(QtCore.Qt.ItemIsEnabled) + item.setData(action_item.full_label, QtCore.Qt.ToolTipRole) + item.setData(action_item.full_label, QtCore.Qt.DisplayRole) + item.setData(icon, QtCore.Qt.DecorationRole) + item.setData(action_item.identifier, ACTION_ID_ROLE) + item.setData(bool(action_item.config_fields), ACTION_HAS_CONFIGS_ROLE) + item.setData(action_item.order, ACTION_SORT_ROLE) + new_items.append(item) + + if new_items: + root_item.appendRows(new_items) + + class ActionMenuPopup(QtWidgets.QFrame): action_triggered = QtCore.Signal(str) config_requested = QtCore.Signal(str) @@ -354,7 +326,7 @@ class ActionMenuPopup(QtWidgets.QFrame): def __init__(self, parent): super().__init__(parent) - self.setWindowFlags(QtCore.Qt.Popup) + self.setWindowFlags(QtCore.Qt.Tool | QtCore.Qt.FramelessWindowHint) self.setAttribute(QtCore.Qt.WA_ShowWithoutActivating, True) self.setAutoFillBackground(True) self.setBackgroundRole(QtGui.QPalette.Base) @@ -369,22 +341,36 @@ class ActionMenuPopup(QtWidgets.QFrame): close_timer.setSingleShot(True) close_timer.setInterval(100) + view = ActionsView(self) + + model = ActionMenuPopupModel() + proxy_model = ActionsProxyModel() + proxy_model.setSourceModel(model) + + view.setModel(proxy_model) + view.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + view.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + main_layout = QtWidgets.QVBoxLayout(self) main_layout.setContentsMargins(0, 0, 0, 0) main_layout.setSpacing(0) + main_layout.addWidget(view, 0) close_timer.timeout.connect(self.close) show_timer.timeout.connect(self._on_show_timer) + view.clicked.connect(self._on_clicked) + view.config_requested.connect(self.config_requested) + + self._view = view + self._model = model + self._proxy_model = proxy_model - self._main_layout = main_layout self._show_timer = show_timer self._close_timer = close_timer self._showed = False - self._mouse_entered = False self._current_id = None self._last_pos = QtCore.QPoint(0, 0) - self._widgets_by_id = {} def showEvent(self, event): self._showed = True @@ -392,110 +378,103 @@ class ActionMenuPopup(QtWidgets.QFrame): def closeEvent(self, event): self._showed = False - self._mouse_entered = False super().closeEvent(event) def enterEvent(self, event): - self._mouse_entered = True - self._close_timer.stop() super().leaveEvent(event) + self._close_timer.stop() def leaveEvent(self, event): - self._mouse_entered = False super().leaveEvent(event) self._close_timer.start() def show_items(self, action_id, action_items, pos): + if not action_items: + if self._showed: + self._close_timer.start() + self._current_id = None + return + self._close_timer.stop() - is_current = action_id == self._current_id - if not is_current: + update_position = False + if action_id != self._current_id: + update_position = True self._current_id = action_id self._update_items(action_items) - # Nothing to show - if not self._widgets_by_id: - if self._showed: - self.close() - return - # Make sure is visible - update_position = not is_current if not self._showed: update_position = True self.show() - if not update_position: - # Only resize if is current - self.resize(self.sizeHint()) - else: + if update_position: # Set geometry to position # - first make sure widget changes from '_update_items' # are recalculated app = QtWidgets.QApplication.instance() app.processEvents() - size = self.sizeHint() - offset = 4 + size = self._get_size_hint() self.setGeometry( - pos.x() + offset, pos.y() + offset, - size.width(), size.height() + pos.x() - 1, pos.y() - 1, + size.width() + 4, size.height() + 4 ) + else: + # Only resize if is current + self._update_size() self.raise_() self._show_timer.start() + def _on_clicked(self, index): + if not index or not index.isValid(): + return + + action_id = index.data(ACTION_ID_ROLE) + self.action_triggered.emit(action_id) + def _on_show_timer(self): - size = self.sizeHint() + self._update_size() + + def _update_size(self): + size = self._get_size_hint() + self._view.setMaximumHeight(size.height()) self.resize(size) + def _get_size_hint(self): + grid_size = self._view.gridSize() + row_count = self._proxy_model.rowCount() + cols = 4 + rows = 1 + while True: + rows = row_count // cols + if row_count % cols: + rows += 1 + if rows <= cols: + break + cols += 1 + + if rows == 1: + cols = row_count + + width = ( + (cols * grid_size.width()) + + ((cols - 1) * self._view.spacing()) + + self._view.horizontalOffset() + 4 + ) + height = ( + (rows * grid_size.height()) + + ((rows - 1) * self._view.spacing()) + + self._view.verticalOffset() + 4 + ) + return QtCore.QSize(width, height) + def _update_items(self, action_items): """Update items in the tooltip.""" # This method can be used to update the content of the tooltip # with new icon, text and settings button visibility. - - remove_ids = set(self._widgets_by_id.keys()) - new_ids = set() - widgets = [] - - any_has_configs = False - prepared_items = [] - for idx, action_item in enumerate(action_items): - has_configs = bool(action_item.config_fields) - if has_configs: - any_has_configs = True - prepared_items.append((idx, action_item, has_configs)) - - if any_has_configs or len(action_items) > 1: - for idx, action_item, has_configs in prepared_items: - widget = self._widgets_by_id.get(action_item.identifier) - icon = get_qt_icon(action_item.icon) - label = action_item.full_label - if widget is None: - widget = ActionVariantWidget( - action_item.identifier, - icon, - label, - has_configs, - self - ) - widget.action_triggered.connect(self._on_trigger) - widget.config_requested.connect( - self._on_configs_trigger - ) - new_ids.add(action_item.identifier) - self._widgets_by_id[action_item.identifier] = widget - else: - remove_ids.discard(action_item.identifier) - widgets.append((idx, widget)) - - for action_id in remove_ids: - widget = self._widgets_by_id.pop(action_id) - widget.setVisible(False) - self._main_layout.removeWidget(widget) - widget.deleteLater() - - for idx, widget in widgets: - self._main_layout.insertWidget(idx, widget, 0) + self._model.set_action_items(action_items) + self._view.update_on_refresh() def _on_trigger(self, action_id): self.action_triggered.emit(action_id) @@ -651,6 +630,8 @@ class ActionsView(QtWidgets.QListView): self.setSelectionMode(QtWidgets.QListView.NoSelection) self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) self.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) + self.setContentsMargins(0, 0, 0, 0) + self.setViewportMargins(0, 0, 0, 0) self.setWrapping(True) self.setGridSize(QtCore.QSize(70, 75)) self.setIconSize(QtCore.QSize(30, 30)) @@ -665,36 +646,79 @@ class ActionsView(QtWidgets.QListView): flick = FlickCharm(parent=self) flick.activateOn(self) - popup_widget = ActionMenuPopup(self) - - popup_widget.action_triggered.connect(self.action_triggered) - popup_widget.config_requested.connect(self.config_requested) - + self._overlay_widgets = [] self._flick = flick self._delegate = delegate - self._popup_widget = popup_widget + self._popup_widget = None def mouseMoveEvent(self, event): """Handle mouse move event.""" super().mouseMoveEvent(event) # Update hover state for the item under mouse index = self.indexAt(event.pos()) - if not index.isValid(): - return - - if index.data(ACTION_IS_GROUP_ROLE): + if index.isValid() and index.data(ACTION_IS_GROUP_ROLE): self._show_group_popup(index) + elif self._popup_widget is not None: + self._popup_widget.close() + + def _get_popup_widget(self): + if self._popup_widget is None: + popup_widget = ActionMenuPopup(self) + + popup_widget.action_triggered.connect(self.action_triggered) + popup_widget.config_requested.connect(self.config_requested) + self._popup_widget = popup_widget + return self._popup_widget + def _show_group_popup(self, index): action_id = index.data(ACTION_ID_ROLE) - source_model = self.model().sourceModel() - action_items = source_model.get_group_items(action_id) - rect = self.rectForIndex(index) + model = self.model() + while hasattr(model, "sourceModel"): + model = model.sourceModel() + + if not hasattr(model, "get_group_items"): + return + + action_items = model.get_group_items(action_id) + rect = self.visualRect(index) pos = self.mapToGlobal(rect.topLeft()) - self._popup_widget.show_items( + + popup_widget = self._get_popup_widget() + popup_widget.show_items( action_id, action_items, pos ) + def update_on_refresh(self): + viewport = self.viewport() + viewport.update() + self._add_overlay_widgets() + + def _add_overlay_widgets(self): + overlay_widgets = [] + viewport = self.viewport() + model = self.model() + for row in range(model.rowCount()): + index = model.index(row, 0) + has_configs = index.data(ACTION_HAS_CONFIGS_ROLE) + widget = None + if has_configs: + item_id = index.data(ACTION_ID_ROLE) + widget = ActionOverlayWidget(item_id, viewport) + widget.config_requested.connect( + self.config_requested + ) + overlay_widgets.append(widget) + self.setIndexWidget(index, widget) + + while self._overlay_widgets: + widget = self._overlay_widgets.pop(0) + widget.setVisible(False) + widget.setParent(None) + widget.deleteLater() + + self._overlay_widgets = overlay_widgets + class ActionsWidget(QtWidgets.QWidget): def __init__(self, controller, parent): @@ -730,8 +754,6 @@ class ActionsWidget(QtWidgets.QWidget): self._model = model self._proxy_model = proxy_model - self._overlay_widgets = [] - self._set_row_height(1) def refresh(self): @@ -782,33 +804,7 @@ class ActionsWidget(QtWidgets.QWidget): def _on_model_refresh(self): self._proxy_model.sort(0) # Force repaint all items - viewport = self._view.viewport() - viewport.update() - self._add_overlay_widgets() - - def _add_overlay_widgets(self): - overlay_widgets = [] - viewport = self._view.viewport() - for row in range(self._proxy_model.rowCount()): - index = self._proxy_model.index(row, 0) - has_configs = index.data(ACTION_HAS_CONFIGS_ROLE) - widget = None - if has_configs: - item_id = index.data(ACTION_ID_ROLE) - widget = ActionOverlayWidget(item_id, viewport) - widget.config_requested.connect( - self._on_config_request - ) - overlay_widgets.append(widget) - self._view.setIndexWidget(index, widget) - - while self._overlay_widgets: - widget = self._overlay_widgets.pop(0) - widget.setVisible(False) - widget.setParent(None) - widget.deleteLater() - - self._overlay_widgets = overlay_widgets + self._view.update_on_refresh() def _on_animation(self): time_now = time.time() From e2bc5471d4446d63193fa9ae4098c76219f21f26 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 28 May 2025 18:26:26 +0200 Subject: [PATCH 084/103] better looks --- client/ayon_core/style/style.css | 31 +++++++++++++++++-- .../tools/launcher/ui/actions_widget.py | 26 +++++++++++----- 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index 3595733774..c13b8b7aef 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -829,8 +829,35 @@ HintedLineEditButton { } /* Launcher specific stylesheets */ -ActionMenuPopup { - border: 1px solid {color:border}; +ActionsView[mode="icon"] { + /* font size can't be set on items */ + font-size: 9pt; + border: 0px; + padding: 0px; + margin: 0px; +} + +ActionsView[mode="icon"]::item { + padding-top: 8px; + padding-bottom: 4px; + border: 0px; + border-radius: 0.3em; +} + +ActionsView[mode="icon"]::item:hover { + color: {color:font-hover}; +} + +ActionsView[mode="icon"]::icon {} + +ActionMenuPopup #Wrapper { + border: 1px solid #f4f5f5; + border-radius: 0.3em; + background: {color:bg-inputs}; +} +ActionMenuPopup ActionsView[mode="icon"] { + background: transparent; + border: none; } LauncherSettingsButton { diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 78e976937f..bc0a129a22 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -319,7 +319,7 @@ class ActionMenuPopupModel(QtGui.QStandardItemModel): root_item.appendRows(new_items) -class ActionMenuPopup(QtWidgets.QFrame): +class ActionMenuPopup(QtWidgets.QWidget): action_triggered = QtCore.Signal(str) config_requested = QtCore.Signal(str) @@ -328,6 +328,7 @@ class ActionMenuPopup(QtWidgets.QFrame): self.setWindowFlags(QtCore.Qt.Tool | QtCore.Qt.FramelessWindowHint) self.setAttribute(QtCore.Qt.WA_ShowWithoutActivating, True) + self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) self.setAutoFillBackground(True) self.setBackgroundRole(QtGui.QPalette.Base) @@ -341,7 +342,10 @@ class ActionMenuPopup(QtWidgets.QFrame): close_timer.setSingleShot(True) close_timer.setInterval(100) - view = ActionsView(self) + wrapper = QtWidgets.QFrame(self) + wrapper.setObjectName("Wrapper") + + view = ActionsView(wrapper) model = ActionMenuPopupModel() proxy_model = ActionsProxyModel() @@ -351,10 +355,15 @@ class ActionMenuPopup(QtWidgets.QFrame): view.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) view.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + wrapper_layout = QtWidgets.QVBoxLayout(wrapper) + wrapper_layout.setContentsMargins(0, 0, 0, 0) + wrapper_layout.setSpacing(0) + wrapper_layout.addWidget(view, 0) + main_layout = QtWidgets.QVBoxLayout(self) main_layout.setContentsMargins(0, 0, 0, 0) main_layout.setSpacing(0) - main_layout.addWidget(view, 0) + main_layout.addWidget(wrapper, 0) close_timer.timeout.connect(self.close) show_timer.timeout.connect(self._on_show_timer) @@ -417,7 +426,7 @@ class ActionMenuPopup(QtWidgets.QFrame): size = self._get_size_hint() self.setGeometry( pos.x() - 1, pos.y() - 1, - size.width() + 4, size.height() + 4 + size.width(), size.height() ) else: # Only resize if is current @@ -457,15 +466,17 @@ class ActionMenuPopup(QtWidgets.QFrame): if rows == 1: cols = row_count + # QUESTION how to get the margins from Qt? + border = 2 * 1 width = ( (cols * grid_size.width()) + ((cols - 1) * self._view.spacing()) - + self._view.horizontalOffset() + 4 + + self._view.horizontalOffset() + border + 1 ) height = ( (rows * grid_size.height()) + ((rows - 1) * self._view.spacing()) - + self._view.verticalOffset() + 4 + + self._view.verticalOffset() + border + 1 ) return QtCore.QSize(width, height) @@ -566,7 +577,7 @@ class ActionDelegate(QtWidgets.QStyledItemDelegate): if index.data(ANIMATION_STATE_ROLE): self._draw_animation(painter, option, index) - + option.displayAlignment = QtCore.Qt.AlignHCenter | QtCore.Qt.AlignTop super().paint(painter, option, index) if not index.data(ACTION_IS_GROUP_ROLE): @@ -624,7 +635,6 @@ class ActionsView(QtWidgets.QListView): def __init__(self, parent): super().__init__(parent) self.setProperty("mode", "icon") - self.setObjectName("IconView") self.setViewMode(QtWidgets.QListView.IconMode) self.setResizeMode(QtWidgets.QListView.Adjust) self.setSelectionMode(QtWidgets.QListView.NoSelection) From 9327cf558a8f197231806eac1618cc962df59b36 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 29 May 2025 15:33:01 +0200 Subject: [PATCH 085/103] change icons and scale view on hover --- client/ayon_core/style/style.css | 22 +----- .../tools/launcher/ui/actions_widget.py | 71 +++++++++++------- .../tools/launcher/ui/resources/__init__.py | 7 -- .../tools/launcher/ui/resources/options.png | Bin 1772 -> 0 bytes client/ayon_core/tools/utils/widgets.py | 2 +- 5 files changed, 47 insertions(+), 55 deletions(-) delete mode 100644 client/ayon_core/tools/launcher/ui/resources/__init__.py delete mode 100644 client/ayon_core/tools/launcher/ui/resources/options.png diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index c13b8b7aef..e6009cf5a9 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -837,7 +837,7 @@ ActionsView[mode="icon"] { margin: 0px; } -ActionsView[mode="icon"]::item { +ActionsView[mode="icon"]::item { padding-top: 8px; padding-bottom: 4px; border: 0px; @@ -846,36 +846,20 @@ ActionsView[mode="icon"]::item { ActionsView[mode="icon"]::item:hover { color: {color:font-hover}; + background: #384350; } ActionsView[mode="icon"]::icon {} ActionMenuPopup #Wrapper { - border: 1px solid #f4f5f5; border-radius: 0.3em; - background: {color:bg-inputs}; + background: #14161A; } ActionMenuPopup ActionsView[mode="icon"] { background: transparent; border: none; } -LauncherSettingsButton { - icon-size: 15px; - background: transparent; - border: 1px solid #f4f5f5; - padding: 1px 3px 1px 3px; -} - -LauncherSettingsButton[inMenu="1"] { - border: none; - padding: 3px 5px 3px 5px; -} - -LauncherSettingsButton:hover, LauncherSettingsButton[inMenu="1"]:hover { - border: 1px solid #f4f5f5; -} - #IconView[mode="icon"] { /* font size can't be set on items */ font-size: 9pt; diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index bc0a129a22..01e6d4a7d1 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -16,15 +16,11 @@ from ayon_core.lib.attribute_definitions import ( from ayon_core.tools.flickcharm import FlickCharm from ayon_core.tools.utils import ( get_qt_icon, - SquareButton, - ClickableFrame, PixmapLabel, ) from ayon_core.tools.attribute_defs import AttributeDefinitionsDialog from ayon_core.tools.launcher.abstract import WebactionContext -from .resources import get_options_image_path - ANIMATION_LEN = 7 ACTION_ID_ROLE = QtCore.Qt.UserRole + 1 @@ -54,12 +50,12 @@ def _variant_label_sort_getter(action_item): # --- Replacement for QAction for action variants --- -class LauncherSettingsButton(SquareButton): +class LauncherSettingsLabel(PixmapLabel): _settings_icon = None def __init__(self, parent): - super().__init__(parent) - self.setIcon(self._get_settings_icon()) + icon = self._get_settings_icon() + super().__init__(icon.pixmap(256, 256), parent) @classmethod def _get_settings_icon(cls): @@ -70,11 +66,6 @@ class LauncherSettingsButton(SquareButton): }) return cls._settings_icon - def set_is_in_menu(self, in_menu): - value = "1" if in_menu else "" - self.setProperty("inMenu", value) - self.style().polish(self) - class ActionOverlayWidget(QtWidgets.QFrame): config_requested = QtCore.Signal(str) @@ -83,21 +74,17 @@ class ActionOverlayWidget(QtWidgets.QFrame): super().__init__(parent) self._item_id = item_id - settings_btn = LauncherSettingsButton(self) + settings_icon = LauncherSettingsLabel(self) + settings_icon.setToolTip("Right click for options") main_layout = QtWidgets.QGridLayout(self) main_layout.setContentsMargins(5, 5, 0, 0) - main_layout.addWidget(settings_btn, 0, 0) + main_layout.addWidget(settings_icon, 0, 0) main_layout.setColumnStretch(1, 1) main_layout.setRowStretch(1, 1) - settings_btn.clicked.connect(self._on_settings_click) - self.setAttribute(QtCore.Qt.WA_TranslucentBackground) - def _on_settings_click(self): - self.config_requested.emit(self._item_id) - class ActionsQtModel(QtGui.QStandardItemModel): """Qt model for actions. @@ -234,7 +221,7 @@ class ActionsQtModel(QtGui.QStandardItemModel): item.setFlags(QtCore.Qt.ItemIsEnabled) item.setData(label, QtCore.Qt.DisplayRole) - item.setData(label, QtCore.Qt.ToolTipRole) + # item.setData(label, QtCore.Qt.ToolTipRole) item.setData(icon, QtCore.Qt.DecorationRole) item.setData(is_group, ACTION_IS_GROUP_ROLE) item.setData(has_configs, ACTION_HAS_CONFIGS_ROLE) @@ -307,7 +294,7 @@ class ActionMenuPopupModel(QtGui.QStandardItemModel): item = QtGui.QStandardItem() item.setFlags(QtCore.Qt.ItemIsEnabled) - item.setData(action_item.full_label, QtCore.Qt.ToolTipRole) + # item.setData(action_item.full_label, QtCore.Qt.ToolTipRole) item.setData(action_item.full_label, QtCore.Qt.DisplayRole) item.setData(icon, QtCore.Qt.DecorationRole) item.setData(action_item.identifier, ACTION_ID_ROLE) @@ -346,6 +333,8 @@ class ActionMenuPopup(QtWidgets.QWidget): wrapper.setObjectName("Wrapper") view = ActionsView(wrapper) + view.setGridSize(QtCore.QSize(75, 80)) + view.setIconSize(QtCore.QSize(32, 32)) model = ActionMenuPopupModel() proxy_model = ActionsProxyModel() @@ -356,7 +345,7 @@ class ActionMenuPopup(QtWidgets.QWidget): view.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) wrapper_layout = QtWidgets.QVBoxLayout(wrapper) - wrapper_layout.setContentsMargins(0, 0, 0, 0) + wrapper_layout.setContentsMargins(3, 3, 1, 1) wrapper_layout.setSpacing(0) wrapper_layout.addWidget(view, 0) @@ -373,6 +362,7 @@ class ActionMenuPopup(QtWidgets.QWidget): self._view = view self._model = model self._proxy_model = proxy_model + self._wrapper_layout = wrapper_layout self._show_timer = show_timer self._close_timer = close_timer @@ -425,7 +415,7 @@ class ActionMenuPopup(QtWidgets.QWidget): app.processEvents() size = self._get_size_hint() self.setGeometry( - pos.x() - 1, pos.y() - 1, + pos.x() - 5, pos.y() - 4, size.width(), size.height() ) else: @@ -439,6 +429,9 @@ class ActionMenuPopup(QtWidgets.QWidget): if not index or not index.isValid(): return + if not index.data(ACTION_HAS_CONFIGS_ROLE): + return + action_id = index.data(ACTION_ID_ROLE) self.action_triggered.emit(action_id) @@ -466,17 +459,18 @@ class ActionMenuPopup(QtWidgets.QWidget): if rows == 1: cols = row_count + m_l, m_t, m_r, m_b = self._wrapper_layout.getContentsMargins() # QUESTION how to get the margins from Qt? border = 2 * 1 width = ( (cols * grid_size.width()) + ((cols - 1) * self._view.spacing()) - + self._view.horizontalOffset() + border + 1 + + self._view.horizontalOffset() + border + m_l + m_r + 1 ) height = ( (rows * grid_size.height()) + ((rows - 1) * self._view.spacing()) - + self._view.verticalOffset() + border + 1 + + self._view.verticalOffset() + border + m_b + m_t + 1 ) return QtCore.QSize(width, height) @@ -498,6 +492,7 @@ class ActionMenuPopup(QtWidgets.QWidget): class ActionDelegate(QtWidgets.QStyledItemDelegate): _cached_extender = {} + _cached_extender_base_pix = None def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -561,7 +556,17 @@ class ActionDelegate(QtWidgets.QStyledItemDelegate): pix = cls._cached_extender.get(size) if pix is not None: return pix - pix = QtGui.QPixmap(get_options_image_path()).scaled( + + base_pix = cls._cached_extender_base_pix + if base_pix is None: + icon = get_qt_icon({ + "type": "material-symbols", + "name": "more_horiz", + }) + base_pix = icon.pixmap(128, 128) + cls._cached_extender_base_pix = base_pix + + pix = base_pix.scaled( size, size, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation @@ -643,8 +648,6 @@ class ActionsView(QtWidgets.QListView): self.setContentsMargins(0, 0, 0, 0) self.setViewportMargins(0, 0, 0, 0) self.setWrapping(True) - self.setGridSize(QtCore.QSize(70, 75)) - self.setIconSize(QtCore.QSize(30, 30)) self.setSpacing(0) self.setWordWrap(True) self.setMouseTracking(True) @@ -656,6 +659,8 @@ class ActionsView(QtWidgets.QListView): flick = FlickCharm(parent=self) flick.activateOn(self) + self.customContextMenuRequested.connect(self._on_context_menu) + self._overlay_widgets = [] self._flick = flick self._delegate = delegate @@ -672,6 +677,14 @@ class ActionsView(QtWidgets.QListView): elif self._popup_widget is not None: self._popup_widget.close() + def _on_context_menu(self, point): + """Creates menu to force skip opening last workfile.""" + index = self.indexAt(point) + if not index.isValid(): + return + action_id = index.data(ACTION_ID_ROLE) + self.config_requested.emit(action_id) + def _get_popup_widget(self): if self._popup_widget is None: popup_widget = ActionMenuPopup(self) @@ -737,6 +750,8 @@ class ActionsWidget(QtWidgets.QWidget): self._controller = controller view = ActionsView(self) + view.setGridSize(QtCore.QSize(70, 75)) + view.setIconSize(QtCore.QSize(30, 30)) model = ActionsQtModel(controller) diff --git a/client/ayon_core/tools/launcher/ui/resources/__init__.py b/client/ayon_core/tools/launcher/ui/resources/__init__.py deleted file mode 100644 index 27c59af2ba..0000000000 --- a/client/ayon_core/tools/launcher/ui/resources/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -import os - -RESOURCES_DIR = os.path.dirname(os.path.abspath(__file__)) - - -def get_options_image_path(): - return os.path.join(RESOURCES_DIR, "options.png") diff --git a/client/ayon_core/tools/launcher/ui/resources/options.png b/client/ayon_core/tools/launcher/ui/resources/options.png deleted file mode 100644 index a9617d0d1914634835f388c01479c672a8c8ffd7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1772 zcmb7^`B%~j7skJcd*X`HFm9u1j=6?t%;Iwb7j zwT|b(sB<3ljDb1wl4oJgfJ(AW&7Bpv&K*RI{SGm)zYu0jaC8|wa}(8OdA=SqaOrGq zQl&JypY*{sIl^1ofVAgks@SbOwLH%}dUorXyCcm^p8M0qpzTd|xz-DUnY^dOKTPeH z@AJ-CoGxRX%>y<5sp+n7Xk3?Ib+6{^?Cg%=d&rthvwyAl5BkShe>;4{WaeF*=5}tm ztt=TDXzceIC%r2%j9B($%Mh%fdW+^DZJMF}7T%OTA(YA8((QYh7f_5?|N5vQJC}2i zC0r;RJ>ErHqPC^l%s{3;^f+m96LRwULUx{B(Jk=u8K5KQK(F2Y*hE<*nGwM+6L<1iX zlK}t@mfr$Is`1VO02RGJA5=K0d~t%GY(ju`e_V2|K}V)SS?KCKM{H&_P7Y3Crp5mD z(`Z|5OJ5s)n{5lU+FfOFe`n~y1vEYAMcpmGYIrBiNw-)XnP|0HU<^ZOl*}9xr(R_s z-ktQ)Rp*UwVBUH#819d^g7kQOs&9HGq+ox_gXKk_@&4ncL<1u#E}<#6%&D0{)?q>@ z?Mq-Gs6s7mESn~{WhJteFd6)`mZ1U=UOLUm;q`|VcDE-YaVZ;Q~Te%Sofmu0UYVg!&X;RuJAt9igF;-A4LhWK?gT_dOlF*OOzD^7K zM0(b%SCz|j<0nL|yHR9|tdKubbevo`S#A{YvDG=|<+k;omrnFfC=_onNlmF}*P%aQ zz^g7hN^R`Pd;|)6L$)FXQese2SGyOAY{6{}Y=kB?Kv#n$PhZdf2ky zC~`-vdo>j`0wm``3CPJbVh^l!T^XN!WuMskOu14otdtzM{FZ(Ri1&Fi@A@7tB8dK~ zde}ZsK7UHkRttC-w4~8JO&(yw0aNwZM}4Ljqg`wPwrbtjQMQjA+>o+2e`K*~z`}^8 z$vMbbLM0Q2PC(c15PkyL_|q$ttu02DN;O3I7xQtiX;sVJ+ymk-oAE{EL?t$|+05dC zI@|D&m@;_t3WNN^wQ{8^-@~D`hWrV+gGfW9Ct4Myk1m@@3?wv$+$1JoCUMX~gcLy% zh~XxkM&oi1d}qIrrDlMz02j145nuS1%F=x$|b>QE}Uw_gWZT>QLRG z(V=0dJffRQo$`Clt5srtbzj?s=cCa_lk?tP8IX-f>!Ws5L@ck1<(p=DzydJQUDGyaDo3`;n#I_7#p1q zhXiA8A329}vHO`;fO84CeMF#Wsx?to(^2@VC=8r2mai3L)W}`t7f{yg}pV5{4$m~-H5NUJkTQN6t*Ssmw zEK7QvnhYK8CMbrSija`c4|B|61&Y%ub36cVDVz$Q7|_Ra`j1sDOF{i9-4hK;j#?7m zg4b(STtTXty_=b>KL8LX)j~j)sZmySrC>jPN|p?KUt2o6^)Rm87RP5K{aekI3Hu$u zbFte3N7JA8*R$kRE2H|HiFjp)Tf?0gH~)_%?0Q*P# Date: Thu, 29 May 2025 15:45:03 +0200 Subject: [PATCH 086/103] use lighter colors --- client/ayon_core/style/style.css | 4 ++-- client/ayon_core/tools/launcher/ui/actions_widget.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index e6009cf5a9..4ef903540e 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -846,14 +846,14 @@ ActionsView[mode="icon"]::item { ActionsView[mode="icon"]::item:hover { color: {color:font-hover}; - background: #384350; + background: #424A57; } ActionsView[mode="icon"]::icon {} ActionMenuPopup #Wrapper { border-radius: 0.3em; - background: #14161A; + background: #353B46; } ActionMenuPopup ActionsView[mode="icon"] { background: transparent; diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 01e6d4a7d1..1097e7bfd0 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -55,7 +55,7 @@ class LauncherSettingsLabel(PixmapLabel): def __init__(self, parent): icon = self._get_settings_icon() - super().__init__(icon.pixmap(256, 256), parent) + super().__init__(icon.pixmap(64, 64), parent) @classmethod def _get_settings_icon(cls): @@ -563,7 +563,7 @@ class ActionDelegate(QtWidgets.QStyledItemDelegate): "type": "material-symbols", "name": "more_horiz", }) - base_pix = icon.pixmap(128, 128) + base_pix = icon.pixmap(64, 64) cls._cached_extender_base_pix = base_pix pix = base_pix.scaled( From 72990d3aa99286a8aa856276fce9ed00e3c6e1da Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 29 May 2025 16:12:40 +0200 Subject: [PATCH 087/103] added animation --- .../tools/launcher/ui/actions_widget.py | 80 +++++++++++++------ 1 file changed, 54 insertions(+), 26 deletions(-) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 1097e7bfd0..41f4990a9b 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -319,16 +319,15 @@ class ActionMenuPopup(QtWidgets.QWidget): self.setAutoFillBackground(True) self.setBackgroundRole(QtGui.QPalette.Base) - # Update size on show - show_timer = QtCore.QTimer() - show_timer.setSingleShot(True) - show_timer.setInterval(5) - # Close widget if is not updated by event close_timer = QtCore.QTimer() close_timer.setSingleShot(True) close_timer.setInterval(100) + expand_anim = QtCore.QVariantAnimation() + expand_anim.setDuration(60) + expand_anim.setEasingCurve(QtCore.QEasingCurve.InOutQuad) + wrapper = QtWidgets.QFrame(self) wrapper.setObjectName("Wrapper") @@ -355,7 +354,9 @@ class ActionMenuPopup(QtWidgets.QWidget): main_layout.addWidget(wrapper, 0) close_timer.timeout.connect(self.close) - show_timer.timeout.connect(self._on_show_timer) + expand_anim.valueChanged.connect(self._on_expand_anim) + expand_anim.finished.connect(self._on_expand_finish) + view.clicked.connect(self._on_clicked) view.config_requested.connect(self.config_requested) @@ -364,12 +365,12 @@ class ActionMenuPopup(QtWidgets.QWidget): self._proxy_model = proxy_model self._wrapper_layout = wrapper_layout - self._show_timer = show_timer self._close_timer = close_timer + self._expand_anim = expand_anim self._showed = False self._current_id = None - self._last_pos = QtCore.QPoint(0, 0) + self._first_anim_frame = False def showEvent(self, event): self._showed = True @@ -413,17 +414,21 @@ class ActionMenuPopup(QtWidgets.QWidget): # are recalculated app = QtWidgets.QApplication.instance() app.processEvents() - size = self._get_size_hint() + size, target_size = self._get_size_hint() self.setGeometry( pos.x() - 5, pos.y() - 4, size.width(), size.height() ) - else: - # Only resize if is current - self._update_size() + self._view.setMaximumHeight(size.height()) + + self._first_anim_frame = True + self._expand_anim.updateCurrentTime(0) + self._expand_anim.setStartValue(size) + self._expand_anim.setEndValue(target_size) + if self._expand_anim.state() != QtCore.QAbstractAnimation.Running: + self._expand_anim.start() self.raise_() - self._show_timer.start() def _on_clicked(self, index): if not index or not index.isValid(): @@ -435,13 +440,24 @@ class ActionMenuPopup(QtWidgets.QWidget): action_id = index.data(ACTION_ID_ROLE) self.action_triggered.emit(action_id) - def _on_show_timer(self): - self._update_size() + def _on_expand_anim(self, value): + if self._first_anim_frame: + _, size = self._get_size_hint() + self._expand_anim.setEndValue(size) + self._first_anim_frame = False - def _update_size(self): - size = self._get_size_hint() - self._view.setMaximumHeight(size.height()) - self.resize(size) + self._view.setMaximumHeight(value.height()) + self.resize(value) + + def _on_expand_finish(self): + # Make sure that size is recalculated if src and targe size is same + if not self._first_anim_frame: + return + + _, size = self._get_size_hint() + self._first_anim_frame = False + self._view.setMaximumHeight(value.height()) + self.resize(value) def _get_size_hint(self): grid_size = self._view.gridSize() @@ -462,17 +478,29 @@ class ActionMenuPopup(QtWidgets.QWidget): m_l, m_t, m_r, m_b = self._wrapper_layout.getContentsMargins() # QUESTION how to get the margins from Qt? border = 2 * 1 - width = ( - (cols * grid_size.width()) - + ((cols - 1) * self._view.spacing()) + single_width = ( + grid_size.width() + self._view.horizontalOffset() + border + m_l + m_r + 1 ) - height = ( - (rows * grid_size.height()) - + ((rows - 1) * self._view.spacing()) + single_height = ( + grid_size.height() + self._view.verticalOffset() + border + m_b + m_t + 1 ) - return QtCore.QSize(width, height) + total_width = single_width + total_height = single_height + if cols > 1: + total_width += ( + (cols - 1) * (self._view.spacing() + grid_size.width()) + ) + + if rows > 1: + total_height += ( + (rows - 1) * (grid_size.height() + self._view.spacing()) + ) + return ( + QtCore.QSize(single_width, single_height), + QtCore.QSize(total_width, total_height) + ) def _update_items(self, action_items): """Update items in the tooltip.""" From d57f4a0000ce743c63633439c058ed19b7747920 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 29 May 2025 16:14:29 +0200 Subject: [PATCH 088/103] formatting fix --- client/ayon_core/tools/launcher/ui/actions_widget.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 41f4990a9b..763baabc60 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -298,7 +298,10 @@ class ActionMenuPopupModel(QtGui.QStandardItemModel): item.setData(action_item.full_label, QtCore.Qt.DisplayRole) item.setData(icon, QtCore.Qt.DecorationRole) item.setData(action_item.identifier, ACTION_ID_ROLE) - item.setData(bool(action_item.config_fields), ACTION_HAS_CONFIGS_ROLE) + item.setData( + bool(action_item.config_fields), + ACTION_HAS_CONFIGS_ROLE + ) item.setData(action_item.order, ACTION_SORT_ROLE) new_items.append(item) From 365b4d85591930110533048eaee1d3e9a572f5cc Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 29 May 2025 16:14:35 +0200 Subject: [PATCH 089/103] fix variable name --- client/ayon_core/tools/launcher/ui/actions_widget.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 763baabc60..30498b3e9a 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -459,8 +459,8 @@ class ActionMenuPopup(QtWidgets.QWidget): _, size = self._get_size_hint() self._first_anim_frame = False - self._view.setMaximumHeight(value.height()) - self.resize(value) + self._view.setMaximumHeight(size.height()) + self.resize(size) def _get_size_hint(self): grid_size = self._view.gridSize() From 0e33ec97d2c4f6603ada98e5dc9b565fc13c9401 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 29 May 2025 17:07:18 +0200 Subject: [PATCH 090/103] change scrolling --- client/ayon_core/tools/launcher/ui/actions_widget.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 30498b3e9a..ecc53e863b 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -675,6 +675,7 @@ class ActionsView(QtWidgets.QListView): self.setResizeMode(QtWidgets.QListView.Adjust) self.setSelectionMode(QtWidgets.QListView.NoSelection) self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) self.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) self.setContentsMargins(0, 0, 0, 0) self.setViewportMargins(0, 0, 0, 0) @@ -683,6 +684,9 @@ class ActionsView(QtWidgets.QListView): self.setWordWrap(True) self.setMouseTracking(True) + vertical_scroll = self.verticalScrollBar() + vertical_scroll.setSingleStep(8) + delegate = ActionDelegate(self) self.setItemDelegate(delegate) From 9981b48e983e90afdffc065ffcc7bcbc4ffd7200 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 5 Jun 2025 16:20:26 +0200 Subject: [PATCH 091/103] change direction of actions popup when on the edge of screen --- .../tools/launcher/ui/actions_widget.py | 182 +++++++++++++----- 1 file changed, 129 insertions(+), 53 deletions(-) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index ecc53e863b..393feb3db9 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -30,8 +30,9 @@ ACTION_HAS_CONFIGS_ROLE = QtCore.Qt.UserRole + 4 ACTION_SORT_ROLE = QtCore.Qt.UserRole + 5 ACTION_ADDON_NAME_ROLE = QtCore.Qt.UserRole + 6 ACTION_ADDON_VERSION_ROLE = QtCore.Qt.UserRole + 7 -ANIMATION_START_ROLE = QtCore.Qt.UserRole + 8 -ANIMATION_STATE_ROLE = QtCore.Qt.UserRole + 9 +PLACEHOLDER_ITEM_ROLE = QtCore.Qt.UserRole + 8 +ANIMATION_START_ROLE = QtCore.Qt.UserRole + 9 +ANIMATION_STATE_ROLE = QtCore.Qt.UserRole + 10 def _variant_label_sort_getter(action_item): @@ -303,11 +304,44 @@ class ActionMenuPopupModel(QtGui.QStandardItemModel): ACTION_HAS_CONFIGS_ROLE ) item.setData(action_item.order, ACTION_SORT_ROLE) + new_items.append(item) if new_items: root_item.appendRows(new_items) + def fill_to_count(self, count: int): + """Fill up items to specifi counter. + + This is needed to visually organize structure or the viewed items. If + items are shown right to left then mouse would not hover over + last item i there are multiple rows that are uneven. This will + fill the "first items" with invisible items so visually it looks + correct. + + Visually it will cause this: + [ ] [ ] [ ] [A] + [A] [A] [A] [A] + + Instead of: + [A] [A] [A] [A] + [A] [ ] [ ] [ ] + + """ + remainders = count - self.rowCount() + if not remainders: + return + + items = [] + for _ in range(remainders): + item = QtGui.QStandardItem() + item.setFlags(QtCore.Qt.NoItemFlags) + item.setData(True, PLACEHOLDER_ITEM_ROLE) + items.append(item) + + root_item = self.invisibleRootItem() + root_item.appendRows(items) + class ActionMenuPopup(QtWidgets.QWidget): action_triggered = QtCore.Signal(str) @@ -319,8 +353,6 @@ class ActionMenuPopup(QtWidgets.QWidget): self.setWindowFlags(QtCore.Qt.Tool | QtCore.Qt.FramelessWindowHint) self.setAttribute(QtCore.Qt.WA_ShowWithoutActivating, True) self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) - self.setAutoFillBackground(True) - self.setBackgroundRole(QtGui.QPalette.Base) # Close widget if is not updated by event close_timer = QtCore.QTimer() @@ -331,12 +363,16 @@ class ActionMenuPopup(QtWidgets.QWidget): expand_anim.setDuration(60) expand_anim.setEasingCurve(QtCore.QEasingCurve.InOutQuad) - wrapper = QtWidgets.QFrame(self) - wrapper.setObjectName("Wrapper") - - view = ActionsView(wrapper) + # View with actions + view = ActionsView(self) view.setGridSize(QtCore.QSize(75, 80)) view.setIconSize(QtCore.QSize(32, 32)) + view.move(QtCore.QPoint(3, 3)) + + # Background draw + wrapper = QtWidgets.QFrame(self) + wrapper.setObjectName("Wrapper") + wrapper.stackUnder(view) model = ActionMenuPopupModel() proxy_model = ActionsProxyModel() @@ -346,16 +382,6 @@ class ActionMenuPopup(QtWidgets.QWidget): view.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) view.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) - wrapper_layout = QtWidgets.QVBoxLayout(wrapper) - wrapper_layout.setContentsMargins(3, 3, 1, 1) - wrapper_layout.setSpacing(0) - wrapper_layout.addWidget(view, 0) - - main_layout = QtWidgets.QVBoxLayout(self) - main_layout.setContentsMargins(0, 0, 0, 0) - main_layout.setSpacing(0) - main_layout.addWidget(wrapper, 0) - close_timer.timeout.connect(self.close) expand_anim.valueChanged.connect(self._on_expand_anim) expand_anim.finished.connect(self._on_expand_finish) @@ -364,16 +390,17 @@ class ActionMenuPopup(QtWidgets.QWidget): view.config_requested.connect(self.config_requested) self._view = view + self._wrapper = wrapper self._model = model self._proxy_model = proxy_model - self._wrapper_layout = wrapper_layout self._close_timer = close_timer self._expand_anim = expand_anim self._showed = False self._current_id = None - self._first_anim_frame = False + self._right_to_left = False + self._last_show_pos = QtCore.QPoint(0, 0) def showEvent(self, event): self._showed = True @@ -411,25 +438,62 @@ class ActionMenuPopup(QtWidgets.QWidget): update_position = True self.show() - if update_position: - # Set geometry to position - # - first make sure widget changes from '_update_items' - # are recalculated - app = QtWidgets.QApplication.instance() - app.processEvents() - size, target_size = self._get_size_hint() - self.setGeometry( - pos.x() - 5, pos.y() - 4, - size.width(), size.height() - ) - self._view.setMaximumHeight(size.height()) + if not update_position: + self.raise_() + return - self._first_anim_frame = True - self._expand_anim.updateCurrentTime(0) - self._expand_anim.setStartValue(size) - self._expand_anim.setEndValue(target_size) - if self._expand_anim.state() != QtCore.QAbstractAnimation.Running: - self._expand_anim.start() + # Set geometry to position + # - first make sure widget changes from '_update_items' + # are recalculated + app = QtWidgets.QApplication.instance() + app.processEvents() + items_count, size, target_size = self._get_size_hint() + self._model.fill_to_count(items_count) + + window = self.screen() + window_geo = window.geometry() + right_to_left = ( + pos.x() + target_size.width() > window_geo.right() + or pos.y() + target_size.height() > window_geo.bottom() + ) + + pos_x = pos.x() - 5 + pos_y = pos.y() - 4 + self._last_show_pos = QtCore.QPoint(pos_x, pos_y) + + wrap_x = wrap_y = 0 + sort_order = QtCore.Qt.DescendingOrder + if right_to_left: + sort_order = QtCore.Qt.AscendingOrder + size_diff = target_size - size + pos_x -= size_diff.width() + pos_y -= size_diff.height() + wrap_x = size_diff.width() + wrap_y = size_diff.height() + + wrap_geo = QtCore.QRect( + wrap_x, wrap_y, size.width(), size.height() + ) + if self._expand_anim.state() == QtCore.QAbstractAnimation.Running: + self._expand_anim.stop() + self._first_anim_frame = True + self._right_to_left = right_to_left + + self._proxy_model.sort(0, sort_order) + self.setUpdatesEnabled(False) + self._view.setMask(wrap_geo) + self._view.setMinimumWidth(target_size.width()) + self._view.setMaximumWidth(target_size.width()) + self._wrapper.setGeometry(wrap_geo) + self.setGeometry( + pos_x, pos_y, + target_size.width(), target_size.height() + ) + self.setUpdatesEnabled(True) + self._expand_anim.updateCurrentTime(0) + self._expand_anim.setStartValue(size) + self._expand_anim.setEndValue(target_size) + self._expand_anim.start() self.raise_() @@ -444,23 +508,30 @@ class ActionMenuPopup(QtWidgets.QWidget): self.action_triggered.emit(action_id) def _on_expand_anim(self, value): - if self._first_anim_frame: - _, size = self._get_size_hint() - self._expand_anim.setEndValue(size) - self._first_anim_frame = False + if not self._showed: + if self._expand_anim.state() == QtCore.QAbstractAnimation.Running: + self._expand_anim.stop() + return - self._view.setMaximumHeight(value.height()) - self.resize(value) + wrapper_geo = self._wrapper.geometry() + wrapper_geo.setWidth(value.width()) + wrapper_geo.setHeight(value.height()) + + if self._right_to_left: + geo = self.geometry() + pos = QtCore.QPoint( + geo.width() - value.width(), + geo.height() - value.height(), + ) + wrapper_geo.setTopLeft(pos) + + self._view.setMask(wrapper_geo) + self._wrapper.setGeometry(wrapper_geo) def _on_expand_finish(self): # Make sure that size is recalculated if src and targe size is same - if not self._first_anim_frame: - return - - _, size = self._get_size_hint() - self._first_anim_frame = False - self._view.setMaximumHeight(size.height()) - self.resize(size) + _, _, size = self._get_size_hint() + self._on_expand_anim(size) def _get_size_hint(self): grid_size = self._view.gridSize() @@ -478,7 +549,7 @@ class ActionMenuPopup(QtWidgets.QWidget): if rows == 1: cols = row_count - m_l, m_t, m_r, m_b = self._wrapper_layout.getContentsMargins() + m_l, m_t, m_r, m_b = (3, 3, 1, 1) # QUESTION how to get the margins from Qt? border = 2 * 1 single_width = ( @@ -501,6 +572,7 @@ class ActionMenuPopup(QtWidgets.QWidget): (rows - 1) * (grid_size.height() + self._view.spacing()) ) return ( + cols * rows, QtCore.QSize(single_width, single_height), QtCore.QSize(total_width, total_height) ) @@ -643,7 +715,11 @@ class ActionsProxyModel(QtCore.QSortFilterProxyModel): self.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) def lessThan(self, left, right): - # Sort by action order and then by label + if left.data(PLACEHOLDER_ITEM_ROLE): + return True + if right.data(PLACEHOLDER_ITEM_ROLE): + return False + left_value = left.data(ACTION_SORT_ROLE) right_value = right.data(ACTION_SORT_ROLE) From b26710ff94ba4689b6d635cdbd686cacf6f1108f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 5 Jun 2025 16:31:41 +0200 Subject: [PATCH 092/103] remove unused variable --- client/ayon_core/tools/launcher/ui/actions_widget.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 393feb3db9..0459999958 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -400,7 +400,6 @@ class ActionMenuPopup(QtWidgets.QWidget): self._showed = False self._current_id = None self._right_to_left = False - self._last_show_pos = QtCore.QPoint(0, 0) def showEvent(self, event): self._showed = True @@ -459,7 +458,6 @@ class ActionMenuPopup(QtWidgets.QWidget): pos_x = pos.x() - 5 pos_y = pos.y() - 4 - self._last_show_pos = QtCore.QPoint(pos_x, pos_y) wrap_x = wrap_y = 0 sort_order = QtCore.Qt.DescendingOrder From 8cb71fdee4682ea81e2f3bff6358b5c9c78287a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 9 Jun 2025 13:49:31 +0200 Subject: [PATCH 093/103] :recycle: refactor function name --- client/ayon_core/pipeline/__init__.py | 4 ++-- client/ayon_core/pipeline/compatibility.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/pipeline/__init__.py b/client/ayon_core/pipeline/__init__.py index 363e8a5218..137736c302 100644 --- a/client/ayon_core/pipeline/__init__.py +++ b/client/ayon_core/pipeline/__init__.py @@ -101,7 +101,7 @@ from .context_tools import ( ) from .compatibility import ( - is_supporting_product_base_type, + is_product_base_type_supported, ) from .workfile import ( @@ -229,5 +229,5 @@ __all__ = ( "uninstall", # Feature detection - "is_supporting_product_base_type", + "is_product_base_type_supported", ) diff --git a/client/ayon_core/pipeline/compatibility.py b/client/ayon_core/pipeline/compatibility.py index de8c8f39a6..f7d48526b7 100644 --- a/client/ayon_core/pipeline/compatibility.py +++ b/client/ayon_core/pipeline/compatibility.py @@ -1,7 +1,7 @@ """Package to handle compatibility checks for pipeline components.""" -def is_supporting_product_base_type() -> bool: +def is_product_base_type_supported() -> bool: """Check support for product base types. This function checks if the current pipeline supports product base types. From 093a6496682a0712074b0f74dcae136381a27857 Mon Sep 17 00:00:00 2001 From: Anh Tu Date: Wed, 11 Jun 2025 09:28:50 +1000 Subject: [PATCH 094/103] Make _update_containers exception more visible --- client/ayon_core/tools/sceneinventory/view.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/tools/sceneinventory/view.py b/client/ayon_core/tools/sceneinventory/view.py index bb95e37d4e..e6eed29757 100644 --- a/client/ayon_core/tools/sceneinventory/view.py +++ b/client/ayon_core/tools/sceneinventory/view.py @@ -959,11 +959,13 @@ class SceneInventoryView(QtWidgets.QTreeView): remove_container(container) self.data_changed.emit() - def _show_version_error_dialog(self, version, item_ids): + def _show_version_error_dialog(self, version, item_ids, exception=None): """Shows QMessageBox when version switch doesn't work Args: version: str or int or None + item_ids (Iterable[str]): List of item ids to run the + exception (Exception, optional): Exception that occurred """ if version == -1: version_str = "latest" @@ -987,11 +989,14 @@ class SceneInventoryView(QtWidgets.QTreeView): dialog.addButton(QtWidgets.QMessageBox.Cancel) + exception = exception or "Unknown error" + msg = ( - "Version update to '{}' failed as representation doesn't exist." + "Version update to '{}' failed with the following error:\n" + "{}." "\n\nPlease update to version with a valid representation" " OR \n use 'Switch Folder' button to change folder." - ).format(version_str) + ).format(version_str, exception) dialog.setText(msg) dialog.exec_() @@ -1105,10 +1110,10 @@ class SceneInventoryView(QtWidgets.QTreeView): container = containers_by_id[item_id] try: update_container(container, item_version) - except AssertionError: + except Exception as e: log.warning("Update failed", exc_info=True) self._show_version_error_dialog( - item_version, [item_id] + item_version, [item_id], e ) finally: # Always update the scene inventory view, even if errors occurred From c74fbc2aff518d9821bbd10222bdf2f22971474b Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 11 Jun 2025 20:38:03 +0200 Subject: [PATCH 095/103] Use data class for `TempData` to get some type hints --- .../plugins/publish/extract_review.py | 231 ++++++++++-------- 1 file changed, 133 insertions(+), 98 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_review.py b/client/ayon_core/plugins/publish/extract_review.py index 87208f5574..df469c6d00 100644 --- a/client/ayon_core/plugins/publish/extract_review.py +++ b/client/ayon_core/plugins/publish/extract_review.py @@ -1,3 +1,4 @@ +from __future__ import annotations import os import re import copy @@ -6,6 +7,7 @@ import shutil import subprocess from abc import ABC, abstractmethod from typing import Dict, Any, Optional +from dataclasses import dataclass, field import tempfile import clique @@ -35,6 +37,39 @@ from ayon_core.pipeline.publish import ( from ayon_core.pipeline.publish.lib import add_repre_files_for_cleanup +@dataclass +class TempData: + """Temporary data used across extractor's process.""" + fps: float + frame_start: int + frame_end: int + handle_start: int + handle_end: int + frame_start_handle: int + frame_end_handle: int + output_frame_start: int + output_frame_end: int + pixel_aspect: float + resolution_width: int + resolution_height: int + origin_repre: dict[str, Any] + input_is_sequence: bool + first_sequence_frame: int + input_allow_bg: bool + with_audio: bool + without_handles: bool + handles_are_set: bool + input_ext: str + explicit_input_paths: list[str] + paths_to_remove: list[str] + + # Set later + full_output_path: str = "" + filled_files: Dict[int, str] = field(default_factory=list) + output_ext_is_image: bool = True + output_is_sequence: bool = True + + def frame_to_timecode(frame: int, fps: float) -> str: """Convert a frame number and FPS to editorial timecode (HH:MM:SS:FF). @@ -405,10 +440,10 @@ class ExtractReview(pyblish.api.InstancePlugin): temp_data = self.prepare_temp_data(instance, repre, output_def) new_frame_files = {} - if temp_data["input_is_sequence"]: + if temp_data.input_is_sequence: self.log.debug("Checking sequence to fill gaps in sequence..") - files = temp_data["origin_repre"]["files"] + files = temp_data.origin_repre["files"] collections = clique.assemble( files, )[0] @@ -423,18 +458,18 @@ class ExtractReview(pyblish.api.InstancePlugin): new_frame_files = self.fill_sequence_gaps_from_existing( collection=collection, staging_dir=new_repre["stagingDir"], - start_frame=temp_data["frame_start"], - end_frame=temp_data["frame_end"], + start_frame=temp_data.frame_start, + end_frame=temp_data.frame_end, ) elif fill_missing_frames == "blank": new_frame_files = self.fill_sequence_gaps_with_blanks( collection=collection, staging_dir=new_repre["stagingDir"], - start_frame=temp_data["frame_start"], - end_frame=temp_data["frame_end"], - resolution_width=temp_data["resolution_width"], - resolution_height=temp_data["resolution_height"], - extension=temp_data["input_ext"], + start_frame=temp_data.frame_start, + end_frame=temp_data.frame_end, + resolution_width=temp_data.resolution_width, + resolution_height=temp_data.resolution_height, + extension=temp_data.input_ext, temp_data=temp_data ) elif fill_missing_frames == "previous_version": @@ -443,8 +478,8 @@ class ExtractReview(pyblish.api.InstancePlugin): staging_dir=new_repre["stagingDir"], instance=instance, current_repre_name=repre["name"], - start_frame=temp_data["frame_start"], - end_frame=temp_data["frame_end"], + start_frame=temp_data.frame_start, + end_frame=temp_data.frame_end, ) # fallback to original workflow if new_frame_files is None: @@ -452,11 +487,11 @@ class ExtractReview(pyblish.api.InstancePlugin): self.fill_sequence_gaps_from_existing( collection=collection, staging_dir=new_repre["stagingDir"], - start_frame=temp_data["frame_start"], - end_frame=temp_data["frame_end"], + start_frame=temp_data.frame_start, + end_frame=temp_data.frame_end, )) elif fill_missing_frames == "only_rendered": - temp_data["explicit_input_paths"] = [ + temp_data.explicit_input_paths = [ os.path.join( new_repre["stagingDir"], file ).replace("\\", "/") @@ -467,10 +502,10 @@ class ExtractReview(pyblish.api.InstancePlugin): # modify range for burnins instance.data["frameStart"] = frame_start instance.data["frameEnd"] = frame_end - temp_data["frame_start"] = frame_start - temp_data["frame_end"] = frame_end + temp_data.frame_start = frame_start + temp_data.frame_end = frame_end - temp_data["filled_files"] = new_frame_files + temp_data.filled_files = new_frame_files # create or update outputName output_name = new_repre.get("outputName", "") @@ -478,7 +513,7 @@ class ExtractReview(pyblish.api.InstancePlugin): if output_name: output_name += "_" output_name += output_def["filename_suffix"] - if temp_data["without_handles"]: + if temp_data.without_handles: output_name += "_noHandles" # add outputName to anatomy format fill_data @@ -491,7 +526,7 @@ class ExtractReview(pyblish.api.InstancePlugin): # like Resolve or Premiere can detect the start frame for e.g. # review output files "timecode": frame_to_timecode( - frame=temp_data["frame_start_handle"], + frame=temp_data.frame_start_handle, fps=float(instance.data["fps"]) ) }) @@ -508,7 +543,7 @@ class ExtractReview(pyblish.api.InstancePlugin): except ZeroDivisionError: # TODO recalculate width and height using OIIO before # conversion - if 'exr' in temp_data["origin_repre"]["ext"]: + if 'exr' in temp_data.origin_repre["ext"]: self.log.warning( ( "Unsupported compression on input files." @@ -531,16 +566,16 @@ class ExtractReview(pyblish.api.InstancePlugin): for filepath in new_frame_files.values(): os.unlink(filepath) - for filepath in temp_data["paths_to_remove"]: + for filepath in temp_data.paths_to_remove: os.unlink(filepath) new_repre.update({ - "fps": temp_data["fps"], + "fps": temp_data.fps, "name": "{}_{}".format(output_name, output_ext), "outputName": output_name, "outputDef": output_def, - "frameStartFtrack": temp_data["output_frame_start"], - "frameEndFtrack": temp_data["output_frame_end"], + "frameStartFtrack": temp_data.output_frame_start, + "frameEndFtrack": temp_data.output_frame_end, "ffmpeg_cmd": subprcs_cmd }) @@ -566,7 +601,7 @@ class ExtractReview(pyblish.api.InstancePlugin): # - there can be more than one collection return isinstance(repre["files"], (list, tuple)) - def prepare_temp_data(self, instance, repre, output_def): + def prepare_temp_data(self, instance, repre, output_def) -> TempData: """Prepare dictionary with values used across extractor's process. All data are collected from instance, context, origin representation @@ -582,7 +617,7 @@ class ExtractReview(pyblish.api.InstancePlugin): output_def (dict): Definition of output of this plugin. Returns: - dict: All data which are used across methods during process. + TempData: All data which are used across methods during process. Their values should not change during process but new keys with values may be added. """ @@ -647,30 +682,30 @@ class ExtractReview(pyblish.api.InstancePlugin): else: ext = os.path.splitext(repre["files"])[1].replace(".", "") - return { - "fps": float(instance.data["fps"]), - "frame_start": frame_start, - "frame_end": frame_end, - "handle_start": handle_start, - "handle_end": handle_end, - "frame_start_handle": frame_start_handle, - "frame_end_handle": frame_end_handle, - "output_frame_start": int(output_frame_start), - "output_frame_end": int(output_frame_end), - "pixel_aspect": instance.data.get("pixelAspect", 1), - "resolution_width": instance.data.get("resolutionWidth"), - "resolution_height": instance.data.get("resolutionHeight"), - "origin_repre": repre, - "input_is_sequence": input_is_sequence, - "first_sequence_frame": first_sequence_frame, - "input_allow_bg": input_allow_bg, - "with_audio": with_audio, - "without_handles": without_handles, - "handles_are_set": handles_are_set, - "input_ext": ext, - "explicit_input_paths": [], # absolute paths to rendered files - "paths_to_remove": [] - } + return TempData( + fps=float(instance.data["fps"]), + frame_start=frame_start, + frame_end=frame_end, + handle_start=handle_start, + handle_end=handle_end, + frame_start_handle=frame_start_handle, + frame_end_handle=frame_end_handle, + output_frame_start=int(output_frame_start), + output_frame_end=int(output_frame_end), + pixel_aspect=instance.data.get("pixelAspect", 1), + resolution_width=instance.data.get("resolutionWidth"), + resolution_height=instance.data.get("resolutionHeight"), + origin_repre=repre, + input_is_sequence=input_is_sequence, + first_sequence_frame=first_sequence_frame, + input_allow_bg=input_allow_bg, + with_audio=with_audio, + without_handles=without_handles, + handles_are_set=handles_are_set, + input_ext=ext, + explicit_input_paths=[], # absolute paths to rendered files + paths_to_remove=[] + ) def _ffmpeg_arguments( self, @@ -691,7 +726,7 @@ class ExtractReview(pyblish.api.InstancePlugin): instance (Instance): Currently processed instance. new_repre (dict): Representation representing output of this process. - temp_data (dict): Base data for successful process. + temp_data (TempData): Base data for successful process. """ # Get FFmpeg arguments from profile presets @@ -733,32 +768,32 @@ class ExtractReview(pyblish.api.InstancePlugin): # Set output frames len to 1 when output is single image if ( - temp_data["output_ext_is_image"] - and not temp_data["output_is_sequence"] + temp_data.output_ext_is_image + and not temp_data.output_is_sequence ): output_frames_len = 1 else: output_frames_len = ( - temp_data["output_frame_end"] - - temp_data["output_frame_start"] + temp_data.output_frame_end + - temp_data.output_frame_start + 1 ) - duration_seconds = float(output_frames_len / temp_data["fps"]) + duration_seconds = float(output_frames_len / temp_data.fps) # Define which layer should be used if layer_name: ffmpeg_input_args.extend(["-layer", layer_name]) - explicit_input_paths = temp_data["explicit_input_paths"] - if temp_data["input_is_sequence"] and not explicit_input_paths: + explicit_input_paths = temp_data.explicit_input_paths + if temp_data.input_is_sequence and not explicit_input_paths: # Set start frame of input sequence (just frame in filename) # - definition of input filepath # - add handle start if output should be without handles - start_number = temp_data["first_sequence_frame"] - if temp_data["without_handles"] and temp_data["handles_are_set"]: - start_number += temp_data["handle_start"] + start_number = temp_data.first_sequence_frame + if temp_data.without_handles and temp_data.handles_are_set: + start_number += temp_data.handle_start ffmpeg_input_args.extend([ "-start_number", str(start_number) ]) @@ -771,32 +806,32 @@ class ExtractReview(pyblish.api.InstancePlugin): # } # Add framerate to input when input is sequence ffmpeg_input_args.extend([ - "-framerate", str(temp_data["fps"]) + "-framerate", str(temp_data.fps) ]) # Add duration of an input sequence if output is video - if not temp_data["output_is_sequence"]: + if not temp_data.output_is_sequence: ffmpeg_input_args.extend([ "-to", "{:0.10f}".format(duration_seconds) ]) - if temp_data["output_is_sequence"] and not explicit_input_paths: + if temp_data.output_is_sequence and not explicit_input_paths: # Set start frame of output sequence (just frame in filename) # - this is definition of an output ffmpeg_output_args.extend([ - "-start_number", str(temp_data["output_frame_start"]) + "-start_number", str(temp_data.output_frame_start) ]) # Change output's duration and start point if should not contain # handles - if temp_data["without_handles"] and temp_data["handles_are_set"]: + if temp_data.without_handles and temp_data.handles_are_set: # Set output duration in seconds ffmpeg_output_args.extend([ "-t", "{:0.10}".format(duration_seconds) ]) # Add -ss (start offset in seconds) if input is not sequence - if not temp_data["input_is_sequence"]: - start_sec = float(temp_data["handle_start"]) / temp_data["fps"] + if not temp_data.input_is_sequence: + start_sec = float(temp_data.handle_start) / temp_data.fps # Set start time without handles # - Skip if start sec is 0.0 if start_sec > 0.0: @@ -805,7 +840,7 @@ class ExtractReview(pyblish.api.InstancePlugin): ]) # Set frame range of output when input or output is sequence - elif temp_data["output_is_sequence"]: + elif temp_data.output_is_sequence: ffmpeg_output_args.extend([ "-frames:v", str(output_frames_len) ]) @@ -813,10 +848,10 @@ class ExtractReview(pyblish.api.InstancePlugin): if not explicit_input_paths: # Add video/image input path ffmpeg_input_args.extend([ - "-i", path_to_subprocess_arg(temp_data["full_input_path"]) + "-i", path_to_subprocess_arg(temp_data.full_input_path) ]) else: - frame_duration = 1 / temp_data["fps"] + frame_duration = 1 / temp_data.fps explicit_frames_meta = tempfile.NamedTemporaryFile( mode="w", prefix="explicit_frames", suffix=".txt", delete=False @@ -826,21 +861,21 @@ class ExtractReview(pyblish.api.InstancePlugin): with open(explicit_frames_path, "w") as fp: lines = [ f"file '{path}'{os.linesep}duration {frame_duration}" - for path in temp_data["explicit_input_paths"] + for path in temp_data.explicit_input_paths ] fp.write("\n".join(lines)) - temp_data["paths_to_remove"].append(explicit_frames_path) + temp_data.paths_to_remove.append(explicit_frames_path) # let ffmpeg use only rendered files, might have gaps ffmpeg_input_args.extend([ "-f", "concat", "-safe", "0", "-i", path_to_subprocess_arg(explicit_frames_path), - "-r", str(temp_data["fps"]) + "-r", str(temp_data.fps) ]) # Add audio arguments if there are any. Skipped when output are images. - if not temp_data["output_ext_is_image"] and temp_data["with_audio"]: + if not temp_data.output_ext_is_image and temp_data.with_audio: audio_in_args, audio_filters, audio_out_args = self.audio_args( instance, temp_data, duration_seconds ) @@ -862,7 +897,7 @@ class ExtractReview(pyblish.api.InstancePlugin): bg_red, bg_green, bg_blue, bg_alpha = bg_color if bg_alpha > 0.0: - if not temp_data["input_allow_bg"]: + if not temp_data.input_allow_bg: self.log.info(( "Output definition has defined BG color input was" " resolved as does not support adding BG." @@ -893,7 +928,7 @@ class ExtractReview(pyblish.api.InstancePlugin): # NOTE This must be latest added item to output arguments. ffmpeg_output_args.append( - path_to_subprocess_arg(temp_data["full_output_path"]) + path_to_subprocess_arg(temp_data.full_output_path) ) return self.ffmpeg_full_args( @@ -1072,7 +1107,7 @@ class ExtractReview(pyblish.api.InstancePlugin): resolution_width: int, resolution_height: int, extension: str, - temp_data: Dict[str, Any] + temp_data: TempData ) -> Optional[Dict[int, str]]: """Fills missing files by blank frame.""" @@ -1089,7 +1124,7 @@ class ExtractReview(pyblish.api.InstancePlugin): blank_frame_path = self._create_blank_frame( staging_dir, extension, resolution_width, resolution_height ) - temp_data["paths_to_remove"].append(blank_frame_path) + temp_data.paths_to_remove.append(blank_frame_path) speedcopy.copyfile(blank_frame_path, hole_fpath) added_files[frame] = hole_fpath @@ -1176,7 +1211,7 @@ class ExtractReview(pyblish.api.InstancePlugin): return added_files - def input_output_paths(self, new_repre, output_def, temp_data): + def input_output_paths(self, new_repre, output_def, temp_data: TempData): """Deduce input nad output file paths based on entered data. Input may be sequence of images, video file or single image file and @@ -1189,11 +1224,11 @@ class ExtractReview(pyblish.api.InstancePlugin): "sequence_file" (if output is sequence) keys to new representation. """ - repre = temp_data["origin_repre"] + repre = temp_data.origin_repre src_staging_dir = repre["stagingDir"] dst_staging_dir = new_repre["stagingDir"] - if temp_data["input_is_sequence"]: + if temp_data.input_is_sequence: collections = clique.assemble(repre["files"])[0] full_input_path = os.path.join( src_staging_dir, @@ -1218,13 +1253,13 @@ class ExtractReview(pyblish.api.InstancePlugin): # Make sure to have full path to one input file full_input_path_single_file = full_input_path - filled_files = temp_data["filled_files"] + filled_files = temp_data.filled_files if filled_files: first_frame, first_file = next(iter(filled_files.items())) if first_file < full_input_path_single_file: self.log.warning(f"Using filled frame: '{first_file}'") full_input_path_single_file = first_file - temp_data["first_sequence_frame"] = first_frame + temp_data.first_sequence_frame = first_frame filename_suffix = output_def["filename_suffix"] @@ -1252,8 +1287,8 @@ class ExtractReview(pyblish.api.InstancePlugin): ) if output_is_sequence: new_repre_files = [] - frame_start = temp_data["output_frame_start"] - frame_end = temp_data["output_frame_end"] + frame_start = temp_data.output_frame_start + frame_end = temp_data.output_frame_end filename_base = "{}_{}".format(filename, filename_suffix) # Temporary template for frame filling. Example output: @@ -1290,18 +1325,18 @@ class ExtractReview(pyblish.api.InstancePlugin): new_repre["stagingDir"] = dst_staging_dir # Store paths to temp data - temp_data["full_input_path"] = full_input_path - temp_data["full_input_path_single_file"] = full_input_path_single_file - temp_data["full_output_path"] = full_output_path + temp_data.full_input_path = full_input_path + temp_data.full_input_path_single_file = full_input_path_single_file + temp_data.full_output_path = full_output_path # Store information about output - temp_data["output_ext_is_image"] = output_ext_is_image - temp_data["output_is_sequence"] = output_is_sequence + temp_data.output_ext_is_image = output_ext_is_image + temp_data.output_is_sequence = output_is_sequence self.log.debug("Input path {}".format(full_input_path)) self.log.debug("Output path {}".format(full_output_path)) - def audio_args(self, instance, temp_data, duration_seconds): + def audio_args(self, instance, temp_data: TempData, duration_seconds): """Prepares FFMpeg arguments for audio inputs.""" audio_in_args = [] audio_filters = [] @@ -1318,7 +1353,7 @@ class ExtractReview(pyblish.api.InstancePlugin): frame_start_ftrack = instance.data.get("frameStartFtrack") if frame_start_ftrack is not None: offset_frames = frame_start_ftrack - audio["offset"] - offset_seconds = offset_frames / temp_data["fps"] + offset_seconds = offset_frames / temp_data.fps if offset_seconds > 0: audio_in_args.append( @@ -1502,7 +1537,7 @@ class ExtractReview(pyblish.api.InstancePlugin): return output - def rescaling_filters(self, temp_data, output_def, new_repre): + def rescaling_filters(self, temp_data: TempData, output_def, new_repre): """Prepare vieo filters based on tags in new representation. It is possible to add letterboxes to output video or rescale to @@ -1522,7 +1557,7 @@ class ExtractReview(pyblish.api.InstancePlugin): self.log.debug("reformat_in_baking: `{}`".format(reformat_in_baking)) # NOTE Skipped using instance's resolution - full_input_path_single_file = temp_data["full_input_path_single_file"] + full_input_path_single_file = temp_data.full_input_path_single_file try: streams = get_ffprobe_streams( full_input_path_single_file, self.log @@ -1547,7 +1582,7 @@ class ExtractReview(pyblish.api.InstancePlugin): break # Get instance data - pixel_aspect = temp_data["pixel_aspect"] + pixel_aspect = temp_data.pixel_aspect if reformat_in_baking: self.log.debug(( "Using resolution from input. It is already " @@ -1642,8 +1677,8 @@ class ExtractReview(pyblish.api.InstancePlugin): # - use instance resolution only if there were not scale changes # that may massivelly affect output 'use_input_res' if not use_input_res and output_width is None or output_height is None: - output_width = temp_data["resolution_width"] - output_height = temp_data["resolution_height"] + output_width = temp_data.resolution_width + output_height = temp_data.resolution_height # Use source's input resolution instance does not have set it. if output_width is None or output_height is None: From c1b92cc9bd378b7f34d1310860cdffcc9cf911cf Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 12 Jun 2025 10:31:11 +0200 Subject: [PATCH 096/103] Apply suggestions from code review Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/tools/sceneinventory/view.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/tools/sceneinventory/view.py b/client/ayon_core/tools/sceneinventory/view.py index e6eed29757..ef7d9a190a 100644 --- a/client/ayon_core/tools/sceneinventory/view.py +++ b/client/ayon_core/tools/sceneinventory/view.py @@ -959,13 +959,13 @@ class SceneInventoryView(QtWidgets.QTreeView): remove_container(container) self.data_changed.emit() - def _show_version_error_dialog(self, version, item_ids, exception=None): + def _show_version_error_dialog(self, version, item_ids, exception): """Shows QMessageBox when version switch doesn't work Args: version: str or int or None item_ids (Iterable[str]): List of item ids to run the - exception (Exception, optional): Exception that occurred + exception (Exception): Exception that occurred """ if version == -1: version_str = "latest" @@ -1110,10 +1110,10 @@ class SceneInventoryView(QtWidgets.QTreeView): container = containers_by_id[item_id] try: update_container(container, item_version) - except Exception as e: + except Exception as exc: log.warning("Update failed", exc_info=True) self._show_version_error_dialog( - item_version, [item_id], e + item_version, [item_id], exc ) finally: # Always update the scene inventory view, even if errors occurred From 489c41bf32d0df188da7270fd5986c52e48a8589 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 12 Jun 2025 10:31:36 +0200 Subject: [PATCH 097/103] Update client/ayon_core/tools/sceneinventory/view.py --- client/ayon_core/tools/sceneinventory/view.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/client/ayon_core/tools/sceneinventory/view.py b/client/ayon_core/tools/sceneinventory/view.py index ef7d9a190a..fdd1bdbe75 100644 --- a/client/ayon_core/tools/sceneinventory/view.py +++ b/client/ayon_core/tools/sceneinventory/view.py @@ -989,8 +989,6 @@ class SceneInventoryView(QtWidgets.QTreeView): dialog.addButton(QtWidgets.QMessageBox.Cancel) - exception = exception or "Unknown error" - msg = ( "Version update to '{}' failed with the following error:\n" "{}." From ea585ba57c6f299613bed5f0d1a1446a0269fc67 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 12 Jun 2025 12:16:55 +0200 Subject: [PATCH 098/103] Better docstring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ondřej Samohel <33513211+antirotor@users.noreply.github.com> --- client/ayon_core/tools/launcher/abstract.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/launcher/abstract.py b/client/ayon_core/tools/launcher/abstract.py index c4404bb9fa..d4f5338cf6 100644 --- a/client/ayon_core/tools/launcher/abstract.py +++ b/client/ayon_core/tools/launcher/abstract.py @@ -434,7 +434,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): action_label: str, form_data: Optional[dict[str, Any]] = None, ): - """Trigger action on given context. + """Trigger action on the given context. Args: context (WebactionContext): Webaction context. From ad317ca30a92be1accc66c2642d9b3b3728278fb Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 12 Jun 2025 13:43:37 +0200 Subject: [PATCH 099/103] get rid of 'to_data' and 'from_data' --- client/ayon_core/tools/launcher/abstract.py | 78 ++++++------------- .../tools/launcher/models/actions.py | 55 +++++++------ 2 files changed, 48 insertions(+), 85 deletions(-) diff --git a/client/ayon_core/tools/launcher/abstract.py b/client/ayon_core/tools/launcher/abstract.py index d4f5338cf6..cc0066c4bf 100644 --- a/client/ayon_core/tools/launcher/abstract.py +++ b/client/ayon_core/tools/launcher/abstract.py @@ -25,77 +25,43 @@ class WebactionContext: addon_version: str +@dataclass class ActionItem: """Item representing single action to trigger. - Args: + Attributes: action_type (Literal["webaction", "local"]): Type of action. identifier (str): Unique identifier of action item. + order (int): Action ordering. label (str): Action label. variant_label (Union[str, None]): Variant label, full label is concatenated with space. Actions are grouped under single action if it has same 'label' and have set 'variant_label'. + full_label (str): Full label, if not set it is generated + from 'label' and 'variant_label'. icon (dict[str, str]): Icon definition. - order (int): Action ordering. addon_name (Optional[str]): Addon name. addon_version (Optional[str]): Addon version. - config_fields (Optional[list[dict]]): Config fields for webaction. - full_label (Optional[str]): Full label, if not set it is generated - from 'label' and 'variant_label'. + config_fields (list[dict]): Config fields for webaction. """ - def __init__( - self, - action_type: str, - identifier: str, - label: str, - variant_label: Optional[str], - icon: dict[str, str], - order: int, - addon_name: Optional[str] = None, - addon_version: Optional[str] = None, - config_fields: Optional[list[dict]] = None, - full_label: Optional[str] = None, - ): - if config_fields is None: - config_fields = [] - self.action_type = action_type - self.identifier = identifier - self.label = label - self.variant_label = variant_label - self.icon = icon - self.order = order - self.addon_name = addon_name - self.addon_version = addon_version - self.config_fields = config_fields - self._full_label = full_label + action_type: str + identifier: str + order: int + label: str + variant_label: Optional[str] + full_label: str + icon: Optional[dict[str, str]] + config_fields: list[dict] + addon_name: Optional[str] = None + addon_version: Optional[str] = None - def copy(self): - return self.from_data(self.to_data()) - - @property - def full_label(self): - if self._full_label is None: - if self.variant_label: - self._full_label = " ".join([self.label, self.variant_label]) - else: - self._full_label = self.label - return self._full_label - - def to_data(self) -> dict[str, Any]: - return { - "identifier": self.identifier, - "label": self.label, - "variant_label": self.variant_label, - "icon": self.icon, - "order": self.order, - "full_label": self._full_label, - "config_fields": copy.deepcopy(self.config_fields), - } - - @classmethod - def from_data(cls, data: dict[str, Any]) -> "ActionItem": - return cls(**data) + @staticmethod + def calculate_full_label(label: str, variant_label: Optional[str]) -> str: + """Calculate full label from label and variant_label.""" + if variant_label: + return " ".join([label, variant_label]) + return label class AbstractLauncherCommon(ABC): diff --git a/client/ayon_core/tools/launcher/models/actions.py b/client/ayon_core/tools/launcher/models/actions.py index 2706af5580..b929cdcba8 100644 --- a/client/ayon_core/tools/launcher/models/actions.py +++ b/client/ayon_core/tools/launcher/models/actions.py @@ -43,18 +43,6 @@ class WebactionResponse: form: Optional[WebactionForm] = None error_message: Optional[str] = None - def to_data(self): - return asdict(self) - - @classmethod - def from_data(cls, data): - data = data.copy() - form = data["form"] - if form: - data["form"] = WebactionForm(**form) - - return cls(**data) - def get_action_icon(action): """Get action icon info. @@ -268,7 +256,7 @@ class ActionsModel: error_message="Failed to trigger webaction.", ) - data = handle_response.to_data() + data = asdict(handle_response) data.update({ "trigger_failed": failed, "trigger_id": trigger_id, @@ -422,17 +410,21 @@ class ActionsModel: group_label = variant_label variant_label = None + full_label = ActionItem.calculate_full_label( + group_label, variant_label + ) action_items.append(ActionItem( - "webaction", - action["identifier"], - group_label, - variant_label, - # action["category"], - icon, - action["order"], - action["addonName"], - action["addonVersion"], - config_fields, + action_type="webaction", + identifier=action["identifier"], + order=action["order"], + label=group_label, + variant_label=variant_label, + full_label=full_label, + icon=icon, + addon_name=action["addonName"], + addon_version=action["addonVersion"], + config_fields=config_fields, + # category=action["category"], )) cache.update_data(action_items) @@ -572,15 +564,20 @@ class ActionsModel: label = action.label or identifier variant_label = getattr(action, "label_variant", None) + full_label = ActionItem.calculate_full_label( + label, variant_label + ) icon = get_action_icon(action) item = ActionItem( - "local", - identifier, - label, - variant_label, - icon, - action.order, + action_type="local", + identifier=identifier, + order=action.order, + label=label, + variant_label=variant_label, + full_label=full_label, + icon=icon, + config_fields=[], ) action_items[identifier] = item self._action_items[project_name] = action_items From d5a52bfa7e2f503b5b5920088e2bd1b42f900f7f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 12 Jun 2025 13:45:13 +0200 Subject: [PATCH 100/103] remove unused import --- client/ayon_core/tools/launcher/abstract.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/tools/launcher/abstract.py b/client/ayon_core/tools/launcher/abstract.py index cc0066c4bf..f6f3e8e560 100644 --- a/client/ayon_core/tools/launcher/abstract.py +++ b/client/ayon_core/tools/launcher/abstract.py @@ -1,6 +1,5 @@ from __future__ import annotations -import copy from abc import ABC, abstractmethod from dataclasses import dataclass from typing import Optional, Any From ef724793a7e9411843237c13f8543ea61bf15039 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 12 Jun 2025 14:13:33 +0200 Subject: [PATCH 101/103] use correct default value --- client/ayon_core/plugins/publish/extract_review.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/extract_review.py b/client/ayon_core/plugins/publish/extract_review.py index df469c6d00..dbc34f0527 100644 --- a/client/ayon_core/plugins/publish/extract_review.py +++ b/client/ayon_core/plugins/publish/extract_review.py @@ -65,7 +65,7 @@ class TempData: # Set later full_output_path: str = "" - filled_files: Dict[int, str] = field(default_factory=list) + filled_files: dict[int, str] = field(default_factory=dict) output_ext_is_image: bool = True output_is_sequence: bool = True From 8edcd1fc654a077efad5c7e0df8fa051e296c59b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 12 Jun 2025 14:13:48 +0200 Subject: [PATCH 102/103] replace 'Dict' with 'dict' --- client/ayon_core/plugins/publish/extract_review.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_review.py b/client/ayon_core/plugins/publish/extract_review.py index dbc34f0527..3fc2185d1a 100644 --- a/client/ayon_core/plugins/publish/extract_review.py +++ b/client/ayon_core/plugins/publish/extract_review.py @@ -6,7 +6,7 @@ import json import shutil import subprocess from abc import ABC, abstractmethod -from typing import Dict, Any, Optional +from typing import Any, Optional from dataclasses import dataclass, field import tempfile @@ -1020,7 +1020,7 @@ class ExtractReview(pyblish.api.InstancePlugin): current_repre_name: str, start_frame: int, end_frame: int - ) -> Optional[Dict[int, str]]: + ) -> Optional[dict[int, str]]: """Tries to replace missing frames from ones from last version""" repre_file_paths = self._get_last_version_files( instance, current_repre_name) @@ -1108,7 +1108,7 @@ class ExtractReview(pyblish.api.InstancePlugin): resolution_height: int, extension: str, temp_data: TempData - ) -> Optional[Dict[int, str]]: + ) -> Optional[dict[int, str]]: """Fills missing files by blank frame.""" blank_frame_path = None @@ -1164,7 +1164,7 @@ class ExtractReview(pyblish.api.InstancePlugin): staging_dir: str, start_frame: int, end_frame: int - ) -> Dict[int, str]: + ) -> dict[int, str]: """Fill missing files in sequence by duplicating existing ones. This will take nearest frame file and copy it with so as to fill From 09e67612b080c7f2aa0d3b2086ddb418977db929 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 12 Jun 2025 14:57:05 +0200 Subject: [PATCH 103/103] moved 'calculate_full_label' to actions model --- client/ayon_core/tools/launcher/abstract.py | 7 ------- client/ayon_core/tools/launcher/models/actions.py | 11 +++++++++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/client/ayon_core/tools/launcher/abstract.py b/client/ayon_core/tools/launcher/abstract.py index f6f3e8e560..1d7dafd62f 100644 --- a/client/ayon_core/tools/launcher/abstract.py +++ b/client/ayon_core/tools/launcher/abstract.py @@ -55,13 +55,6 @@ class ActionItem: addon_name: Optional[str] = None addon_version: Optional[str] = None - @staticmethod - def calculate_full_label(label: str, variant_label: Optional[str]) -> str: - """Calculate full label from label and variant_label.""" - if variant_label: - return " ".join([label, variant_label]) - return label - class AbstractLauncherCommon(ABC): @abstractmethod diff --git a/client/ayon_core/tools/launcher/models/actions.py b/client/ayon_core/tools/launcher/models/actions.py index b929cdcba8..0ed4bdad3a 100644 --- a/client/ayon_core/tools/launcher/models/actions.py +++ b/client/ayon_core/tools/launcher/models/actions.py @@ -108,6 +108,13 @@ class ActionsModel: self._variant = get_settings_variant() + @staticmethod + def calculate_full_label(label: str, variant_label: Optional[str]) -> str: + """Calculate full label from label and variant_label.""" + if variant_label: + return " ".join([label, variant_label]) + return label + @property def log(self): if self._log is None: @@ -410,7 +417,7 @@ class ActionsModel: group_label = variant_label variant_label = None - full_label = ActionItem.calculate_full_label( + full_label = self.calculate_full_label( group_label, variant_label ) action_items.append(ActionItem( @@ -564,7 +571,7 @@ class ActionsModel: label = action.label or identifier variant_label = getattr(action, "label_variant", None) - full_label = ActionItem.calculate_full_label( + full_label = self.calculate_full_label( label, variant_label ) icon = get_action_icon(action)