diff --git a/client/ayon_core/lib/__init__.py b/client/ayon_core/lib/__init__.py index 8d8cc6af49..477eb29c28 100644 --- a/client/ayon_core/lib/__init__.py +++ b/client/ayon_core/lib/__init__.py @@ -62,6 +62,7 @@ run_subprocess, run_detached_process, run_ayon_launcher_process, + run_detached_ayon_launcher_process, path_to_subprocess_arg, CREATE_NO_WINDOW ) @@ -131,6 +132,7 @@ is_staging_enabled, is_dev_mode_enabled, is_in_tests, + get_settings_variant, ) terminal = Terminal @@ -160,6 +162,7 @@ "run_subprocess", "run_detached_process", "run_ayon_launcher_process", + "run_detached_ayon_launcher_process", "path_to_subprocess_arg", "CREATE_NO_WINDOW", @@ -240,4 +243,5 @@ "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/lib/execute.py b/client/ayon_core/lib/execute.py index 516ea958f5..27af3d44ca 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_launcher_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_detached_ayon_launcher_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. 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 @@ def _use_bundles(cls): @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 diff --git a/client/ayon_core/tools/attribute_defs/dialog.py b/client/ayon_core/tools/attribute_defs/dialog.py index ef717d576a..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): - super(AttributeDefinitionsDialog, self).__init__(parent) + 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,10 +60,33 @@ def __init__(self, attr_defs, parent=None): 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) diff --git a/client/ayon_core/tools/attribute_defs/widgets.py b/client/ayon_core/tools/attribute_defs/widgets.py index dbd65fd215..1e948b2d28 100644 --- a/client/ayon_core/tools/attribute_defs/widgets.py +++ b/client/ayon_core/tools/attribute_defs/widgets.py @@ -22,6 +22,7 @@ FocusSpinBox, FocusDoubleSpinBox, MultiSelectionComboBox, + MarkdownLabel, PlaceholderLineEdit, PlaceholderPlainTextEdit, set_style_property, @@ -247,12 +248,10 @@ def add_attr_defs(self, attr_defs): 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: @@ -350,7 +349,7 @@ def _ui_init(self): 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)) diff --git a/client/ayon_core/tools/launcher/abstract.py b/client/ayon_core/tools/launcher/abstract.py index ea0842f24d..004d03bccb 100644 --- a/client/ayon_core/tools/launcher/abstract.py +++ b/client/ayon_core/tools/launcher/abstract.py @@ -295,34 +295,76 @@ def get_action_items(self, project_name, folder_id, task_id): pass @abstractmethod - def trigger_action(self, project_name, folder_id, task_id, action_id): + def trigger_action( + self, + action_id, + project_name, + folder_id, + task_id, + ): """Trigger action on given context. Args: + 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. """ pass @abstractmethod - def set_application_force_not_open_workfile( - self, project_name, folder_id, task_id, action_ids, enabled + def trigger_webaction( + self, + identifier, + project_name, + folder_id, + task_id, + action_label, + addon_name, + addon_version, + form_data=None, ): - """This is application action related to force not open last workfile. + """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_ids (Iterable[str]): Action identifiers. - enabled (bool): New value of force not open workfile. + 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 + @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 45cb2b7945..a69288dda1 100644 --- a/client/ayon_core/tools/launcher/control.py +++ b/client/ayon_core/tools/launcher/control.py @@ -32,7 +32,7 @@ def log(self): @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,16 +135,79 @@ def get_action_items(self, project_name, folder_id, task_id): 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 + def trigger_action( + self, + identifier, + project_name, + folder_id, + task_id, ): - self._actions_model.set_application_force_not_open_workfile( - project_name, folder_id, task_id, action_ids, enabled + self._actions_model.trigger_action( + identifier, + project_name, + folder_id, + task_id, ) - def trigger_action(self, project_name, folder_id, task_id, identifier): - self._actions_model.trigger_action( - project_name, folder_id, task_id, identifier) + 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( + 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): diff --git a/client/ayon_core/tools/launcher/models/actions.py b/client/ayon_core/tools/launcher/models/actions.py index e1612e2b9f..ad1ea3835f 100644 --- a/client/ayon_core/tools/launcher/models/actions.py +++ b/client/ayon_core/tools/launcher/models/actions.py @@ -1,149 +1,26 @@ import os +import copy +from dataclasses import dataclass, asdict +from urllib.parse import urlencode, urlparse +from typing import Any, Optional +import webbrowser + +import ayon_api from ayon_core import resources -from ayon_core.lib import Logger, AYONSettingsRegistry +from ayon_core.lib import ( + Logger, + NestedCacheItem, + CacheItem, + get_settings_variant, + run_detached_ayon_launcher_process, +) 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: -# 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: @@ -153,6 +30,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 @@ -160,31 +38,37 @@ 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. + 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, - is_application, - force_not_open_workfile, + 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.is_application = is_application - self.force_not_open_workfile = force_not_open_workfile + self.addon_name = addon_name + self.addon_version = addon_version + self.config_fields = config_fields self._full_label = full_label def copy(self): @@ -206,9 +90,8 @@ def to_data(self): "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, + "config_fields": copy.deepcopy(self.config_fields), } @classmethod @@ -216,6 +99,38 @@ def from_data(cls, data): 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. @@ -264,8 +179,6 @@ class ActionsModel: controller (AbstractLauncherBackend): Controller instance. """ - _not_open_workfile_reg_key = "force_not_open_workfile" - def __init__(self, controller): self._controller = controller @@ -274,11 +187,14 @@ def __init__(self, controller): self._discovered_actions = None self._actions = None self._action_items = {} - - self._launcher_tool_reg = AYONSettingsRegistry("launcher_tool") + self._webaction_items = NestedCacheItem( + levels=2, default_factory=list + ) self._addons_manager = None + self._variant = get_settings_variant() + @property def log(self): if self._log is None: @@ -294,34 +210,6 @@ def refresh(self): 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,48 +220,25 @@ def get_action_items(self, project_name, folder_id, task_id): 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]) + output.extend(self._get_webactions(selection)) - 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 + def trigger_action( + self, + identifier, + project_name, + folder_id, + task_id, ): - 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) failed = False error_message = None @@ -390,18 +255,6 @@ def trigger_action(self, project_name, folder_id, task_id, identifier): "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: @@ -419,32 +272,156 @@ def trigger_action(self, project_name, folder_id, task_id, identifier): } ) + 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, + } + 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, + 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() 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: @@ -458,6 +435,175 @@ def _prepare_selection(self, project_name, folder_id, task_id): project_settings=project_settings, ) + def _get_webaction_context(self, selection: LauncherActionSelection): + if not selection.is_project_selected: + return None + + 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 + 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() + 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.get("icon") + 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"] + 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"], + icon, + action["order"], + action["addonName"], + action["addonVersion"], + config_fields, + )) + + cache.update_data(action_items) + return cache.get_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": + return WebactionResponse( + response_type, + False, + error_message="Please use AYON web UI to run the action.", + ) + + payload = data.get("payload") or {} + + download_uri = payload.get("extra_download") + if download_uri is not None: + # 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( + 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": + 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 + # NOTE This does pass environment variables of current process + # to the subprocess. + # NOTE We could 'take action' directly and use the arguments here + if payload is not None: + uri = payload["uri"] + else: + uri = data["uri"] + run_detached_ayon_launcher_process(uri) + + 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: # NOTE We don't need to register the paths, but that would @@ -470,7 +616,6 @@ def _get_discovered_action_classes(self): register_launcher_action_path(path) self._discovered_actions = ( discover_launcher_actions() - + self._get_applications_action_classes() ) return self._discovered_actions @@ -498,10 +643,9 @@ def _get_action_items(self, project_name): 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 @@ -510,50 +654,13 @@ def _get_action_items(self, project_name): icon = get_action_icon(action) item = ActionItem( + "local", identifier, label, 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..3d96b90b6e 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -1,22 +1,34 @@ import time +import uuid import collections from qtpy import QtWidgets, QtCore, QtGui +from ayon_core.lib import Logger +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 ANIMATION_LEN = 7 ACTION_ID_ROLE = QtCore.Qt.UserRole + 1 -ACTION_IS_APPLICATION_ROLE = QtCore.Qt.UserRole + 2 +ACTION_TYPE_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_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): @@ -44,7 +56,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", @@ -122,12 +135,25 @@ def refresh(self): 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 = {} 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 = 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()) + if is_group: label = action_item.label else: @@ -143,12 +169,10 @@ def refresh(self): 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) - 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 @@ -166,6 +190,12 @@ def refresh(self): 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 @@ -263,13 +293,6 @@ def paint(self, painter, option, index): 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 @@ -366,18 +389,55 @@ def __init__(self, controller, parent): self._animated_items = set() self._animation_timer = animation_timer - self._context_menu = None - self._flick = flick self._view = view self._model = model self._proxy_model = proxy_model + self._config_widget = None + self._set_row_height(1) 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"] + 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: + 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) @@ -416,54 +476,6 @@ def _start_animation(self, index): 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 @@ -474,14 +486,33 @@ def _on_clicked(self, index): 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) + + 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) - if not is_group: - self._controller.trigger_action( - project_name, folder_id, task_id, action_id - ) - self._start_animation(index) - return + 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) @@ -494,11 +525,170 @@ def _on_clicked(self, index): 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 + + config_fields = self._model.get_action_config_fields(action_id) + if not config_fields: return - action_item = actions_mapping[result] + 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=project_name, + folder_id=folder_id, + task_id=task_id, + addon_name=addon_name, + addon_version=addon_version, + ) - self._controller.trigger_action( - project_name, folder_id, task_id, action_item.identifier + dialog = self._create_attrs_dialog( + config_fields, + "Action Config", + "Save", + "Cancel", ) - self._start_animation(index) + dialog.set_values(values) + result = dialog.exec_() + 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, + ) + + 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' + 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: + field_type = config_field["type"] + attr_def = None + if field_type == "label": + label = config_field.get("value") + if label is None: + label = config_field.get("text") + attr_def = UILabelDef( + 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=value, + label=config_field.get("label"), + ) + elif field_type == "text": + attr_def = TextDef( + config_field["name"], + default=config_field.get("value"), + 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"): + 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, + 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"), + ) + elif field_type in ("select", "multiselect"): + attr_def = EnumDef( + config_field["name"], + items=config_field["options"], + default=config_field.get("value"), + label=config_field.get("label"), + multiselection=field_type == "multiselect", + ) + elif field_type == "hidden": + attr_def = HiddenDef( + config_field["name"], + default=config_field.get("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, + 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 diff --git a/client/ayon_core/tools/launcher/ui/window.py b/client/ayon_core/tools/launcher/ui/window.py index aa336108ed..3f3e4bb1de 100644 --- a/client/ayon_core/tools/launcher/ui/window.py +++ b/client/ayon_core/tools/launcher/ui/window.py @@ -128,6 +128,14 @@ def __init__(self, controller=None, parent=None): "action.trigger.finished", self._on_action_trigger_finished, ) + controller.register_event_callback( + "webaction.trigger.started", + self._on_webaction_trigger_started, + ) + controller.register_event_callback( + "webaction.trigger.finished", + self._on_webaction_trigger_finished, + ) self._controller = controller @@ -223,6 +231,25 @@ def _on_action_trigger_finished(self, event): return self._echo("Failed: {}".format(event["error_message"])) + 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(clipboard_text) + + # 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 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 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 @@ CustomTextComboBox, PlaceholderLineEdit, PlaceholderPlainTextEdit, + MarkdownLabel, ElideLabel, HintedLineEdit, ExpandingTextEdit, @@ -91,6 +92,7 @@ "CustomTextComboBox", "PlaceholderLineEdit", "PlaceholderPlainTextEdit", + "MarkdownLabel", "ElideLabel", "HintedLineEdit", "ExpandingTextEdit", 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 @@ 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 4b303c0143..9ee89fbf3a 100644 --- a/client/ayon_core/tools/utils/lib.py +++ b/client/ayon_core/tools/utils/lib.py @@ -1,11 +1,14 @@ import os import sys +import io import contextlib import collections import traceback +import urllib.request from functools import partial from typing import Union, Any +import ayon_api from qtpy import QtWidgets, QtCore, QtGui import qtawesome import qtmaterialsymbols @@ -17,7 +20,12 @@ 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__) @@ -480,11 +488,27 @@ def _get_cache_key(cls, icon_def): 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] + + 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 @@ -505,7 +529,7 @@ def get_icon(cls, icon_def): 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( @@ -513,10 +537,40 @@ def get_icon(cls, icon_def): 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) + elif icon_type == "url": + url = icon_def["url"] + try: + content = urllib.request.urlopen(url).read() + pix = QtGui.QPixmap() + pix.loadFromData(content) + icon = QtGui.QIcon(pix) + except Exception: + log.warning( + f"Failed to download image '{url}'", exc_info=True + ) + icon = None + + elif icon_type == "ayon_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() + 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 diff --git a/client/ayon_core/tools/utils/widgets.py b/client/ayon_core/tools/utils/widgets.py index 0cd6d68ab3..a19b0a8966 100644 --- a/client/ayon_core/tools/utils/widgets.py +++ b/client/ayon_core/tools/utils/widgets.py @@ -6,6 +6,11 @@ import qargparse import qtawesome +try: + import markdown +except (ImportError, SyntaxError, TypeError): + markdown = None + from ayon_core.style import ( get_objected_colors, get_style_image_path, @@ -131,6 +136,29 @@ def __init__(self, *args, **kwargs): 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"