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..7c6efde35c 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,29 +202,9 @@ 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: dict +) -> dict[str, str]: env = kwargs.pop("env", None) # Keep env untouched if are passed and not empty if not env: @@ -239,8 +220,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 +294,67 @@ def run_detached_process(args, **kwargs): return process +def run_ayon_launcher_process( + *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 + 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: bool = False, **kwargs +) -> subprocess.Popen: + """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: + subprocess.Popen: Pointer to launched process but it is possible that + launched process is already killed (on linux). + + """ + 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/style/style.css b/client/ayon_core/style/style.css index 0e19702d53..4ef903540e 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -829,6 +829,37 @@ HintedLineEditButton { } /* Launcher specific stylesheets */ +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}; + background: #424A57; +} + +ActionsView[mode="icon"]::icon {} + +ActionMenuPopup #Wrapper { + border-radius: 0.3em; + background: #353B46; +} +ActionMenuPopup ActionsView[mode="icon"] { + background: transparent; + border: none; +} + #IconView[mode="icon"] { /* font size can't be set on items */ font-size: 9pt; 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..1d7dafd62f 100644 --- a/client/ayon_core/tools/launcher/abstract.py +++ b/client/ayon_core/tools/launcher/abstract.py @@ -1,4 +1,59 @@ +from __future__ import annotations + from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Optional, Any + +from ayon_core.tools.common_models import ( + ProjectItem, + FolderItem, + FolderTypeItem, + TaskItem, + 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 + + +@dataclass +class ActionItem: + """Item representing single action to trigger. + + 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. + addon_name (Optional[str]): Addon name. + addon_version (Optional[str]): Addon version. + config_fields (list[dict]): Config fields for webaction. + + """ + 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 class AbstractLauncherCommon(ABC): @@ -88,7 +143,9 @@ def get_task_entity(self, project_name, task_id): 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 @@ -106,7 +163,9 @@ def get_project_items(self, sender=None): 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 @@ -126,7 +185,9 @@ def get_folder_type_items(self, project_name, sender=None): 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 @@ -146,7 +207,9 @@ def get_task_type_items(self, project_name, sender=None): 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 @@ -165,7 +228,9 @@ def get_folder_items(self, project_name, sender=None): 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 @@ -185,7 +250,7 @@ def get_task_items(self, project_name, folder_id, sender=None): pass @abstractmethod - def get_selected_project_name(self): + def get_selected_project_name(self) -> Optional[str]: """Selected project name. Returns: @@ -195,7 +260,7 @@ def get_selected_project_name(self): pass @abstractmethod - def get_selected_folder_id(self): + def get_selected_folder_id(self) -> Optional[str]: """Selected folder id. Returns: @@ -205,7 +270,7 @@ def get_selected_folder_id(self): pass @abstractmethod - def get_selected_task_id(self): + def get_selected_task_id(self) -> Optional[str]: """Selected task id. Returns: @@ -215,7 +280,7 @@ def get_selected_task_id(self): pass @abstractmethod - def get_selected_task_name(self): + def get_selected_task_name(self) -> Optional[str]: """Selected task name. Returns: @@ -225,7 +290,7 @@ def get_selected_task_name(self): pass @abstractmethod - def get_selected_context(self): + def get_selected_context(self) -> dict[str, Optional[str]]: """Get whole selected context. Example: @@ -243,7 +308,7 @@ def get_selected_context(self): pass @abstractmethod - def set_selected_project(self, project_name): + def set_selected_project(self, project_name: Optional[str]): """Change selected folder. Args: @@ -254,7 +319,7 @@ def set_selected_project(self, project_name): pass @abstractmethod - def set_selected_folder(self, folder_id): + def set_selected_folder(self, folder_id: Optional[str]): """Change selected folder. Args: @@ -265,7 +330,9 @@ def set_selected_folder(self, folder_id): 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: @@ -279,7 +346,12 @@ def set_selected_task(self, task_id, task_name): # 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: @@ -295,30 +367,67 @@ 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: str, + project_name: Optional[str], + folder_id: Optional[str], + task_id: Optional[str], + ): """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, + context: WebactionContext, + action_label: str, + form_data: Optional[dict[str, Any]] = None, ): - """This is application action related to force not open last workfile. + """Trigger action on the given context. 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. + context (WebactionContext): Webaction context. + action_label (str): Action label. + form_data (Optional[dict[str, Any]]): Form values of action. + + """ + pass + + @abstractmethod + def get_action_config_values( + 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, + context: WebactionContext, + values: dict[str, Any], + ): + """Set action config values. + + Args: + context (WebactionContext): Webaction context. + values (dict[str, Any]): Action config values. """ pass @@ -343,14 +452,16 @@ def refresh_actions(self): 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 diff --git a/client/ayon_core/tools/launcher/control.py b/client/ayon_core/tools/launcher/control.py index 45cb2b7945..fb9f950bd1 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,30 @@ 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, context, action_label, form_data=None): + self._actions_model.trigger_webaction( + context, action_label, form_data + ) + + def get_action_config_values(self, context): + return self._actions_model.get_action_config_values(context) + + 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 e1612e2b9f..0ed4bdad3a 100644 --- a/client/ayon_core/tools/launcher/models/actions.py +++ b/client/ayon_core/tools/launcher/models/actions.py @@ -1,219 +1,47 @@ import os +import uuid +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: - """Item representing single action to trigger. - - Todos: - Get rid of application specific logic. - - Args: - 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. - 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'. - """ +from ayon_core.tools.launcher.abstract import ActionItem, WebactionContext - def __init__( - self, - identifier, - label, - variant_label, - icon, - order, - is_application, - force_not_open_workfile, - full_label=None - ): - 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._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 +@dataclass +class WebactionForm: + fields: list[dict[str, Any]] + title: str + submit_label: str + submit_icon: str + cancel_label: str + cancel_icon: str - def to_data(self): - return { - "identifier": self.identifier, - "label": self.label, - "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, - } - @classmethod - def from_data(cls, data): - return cls(**data) +@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 get_action_icon(action): @@ -264,8 +92,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 +100,21 @@ 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, lifetime=20, + ) self._addons_manager = None + 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: @@ -289,39 +125,12 @@ def refresh(self): 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() 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,53 +141,31 @@ 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 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] @@ -386,22 +173,11 @@ def trigger_action(self, project_name, folder_id, task_id, identifier): self._controller.emit_event( "action.trigger.started", { + "trigger_id": trigger_id, "identifier": 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: @@ -412,6 +188,7 @@ def trigger_action(self, project_name, folder_id, task_id, identifier): self._controller.emit_event( "action.trigger.finished", { + "trigger_id": trigger_id, "identifier": identifier, "failed": failed, "error_message": error_message, @@ -419,32 +196,148 @@ def trigger_action(self, project_name, folder_id, task_id, identifier): } ) + 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) + 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)}" + request_data = { + "projectName": project_name, + "entityType": entity_type, + "entityIds": entity_ids, + } + if form_data is not None: + request_data["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, + } + ) + + 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=request_data + ) + response.raise_for_status() + 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", + False, + error_message="Failed to trigger webaction.", + ) + + data = asdict(handle_response) + data.update({ + "trigger_failed": failed, + "trigger_id": trigger_id, + "identifier": identifier, + "full_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, context: WebactionContext): + selection = self._prepare_selection( + context.project_name, context.folder_id, context.task_id + ) + if not selection.is_project_selected: + return {} + + request_data = self._get_webaction_request_data(selection) + + query = { + "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, **request_data) + 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, context, values): + selection = self._prepare_selection( + context.project_name, context.folder_id, context.task_id + ) + if not selection.is_project_selected: + return {} + + request_data = self._get_webaction_request_data(selection) + request_data["value"] = values + + query = { + "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, **request_data) + 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 +351,179 @@ def _prepare_selection(self, project_name, folder_id, task_id): project_settings=project_settings, ) + def _get_webaction_request_data(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 [] + + request_data = self._get_webaction_request_data(selection) + project_name = selection.project_name + entity_id = None + 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", **request_data) + 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 + + full_label = self.calculate_full_label( + group_label, variant_label + ) + action_items.append(ActionItem( + 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) + 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 +536,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,62 +563,29 @@ 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 label = action.label or identifier variant_label = getattr(action, "label_variant", None) + full_label = self.calculate_full_label( + label, variant_label + ) icon = get_action_icon(action) item = ActionItem( - identifier, - label, - variant_label, - icon, - action.order, - is_application, - False + 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 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..0459999958 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -1,22 +1,38 @@ 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 .resources import get_options_image_path +from ayon_core.tools.utils import ( + get_qt_icon, + PixmapLabel, +) +from ayon_core.tools.attribute_defs import AttributeDefinitionsDialog +from ayon_core.tools.launcher.abstract import WebactionContext ANIMATION_LEN = 7 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_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 +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): @@ -34,6 +50,43 @@ def _variant_label_sort_getter(action_item): return action_item.variant_label or "" +# --- Replacement for QAction for action variants --- +class LauncherSettingsLabel(PixmapLabel): + _settings_icon = None + + def __init__(self, parent): + icon = self._get_settings_icon() + super().__init__(icon.pixmap(64, 64), parent) + + @classmethod + def _get_settings_icon(cls): + if cls._settings_icon is None: + cls._settings_icon = get_qt_icon({ + "type": "material-symbols", + "name": "settings", + }) + return cls._settings_icon + + +class ActionOverlayWidget(QtWidgets.QFrame): + config_requested = QtCore.Signal(str) + + def __init__(self, item_id, parent): + super().__init__(parent) + self._item_id = item_id + + settings_icon = LauncherSettingsLabel(self) + settings_icon.setToolTip("Right click for options") + + main_layout = QtWidgets.QGridLayout(self) + main_layout.setContentsMargins(5, 5, 0, 0) + main_layout.addWidget(settings_icon, 0, 0) + main_layout.setColumnStretch(1, 1) + main_layout.setRowStretch(1, 1) + + self.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + class ActionsQtModel(QtGui.QStandardItemModel): """Qt model for actions. @@ -44,7 +97,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", @@ -84,6 +138,17 @@ def get_group_items(self, action_id): 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) @@ -108,8 +173,10 @@ def refresh(self): 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: @@ -122,16 +189,30 @@ 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: + 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: @@ -141,16 +222,15 @@ def refresh(self): 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) + 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 if new_items: root_item.appendRows(new_items) @@ -166,6 +246,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 @@ -185,14 +271,341 @@ def _on_selection_task_changed(self, event): 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) + + 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) + config_requested = QtCore.Signal(str) + + def __init__(self, parent): + super().__init__(parent) + + self.setWindowFlags(QtCore.Qt.Tool | QtCore.Qt.FramelessWindowHint) + self.setAttribute(QtCore.Qt.WA_ShowWithoutActivating, True) + self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + + # 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) + + # 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() + proxy_model.setSourceModel(model) + + view.setModel(proxy_model) + view.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + view.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + + close_timer.timeout.connect(self.close) + 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) + + self._view = view + self._wrapper = wrapper + self._model = model + self._proxy_model = proxy_model + + self._close_timer = close_timer + self._expand_anim = expand_anim + + self._showed = False + self._current_id = None + self._right_to_left = False + + def showEvent(self, event): + self._showed = True + super().showEvent(event) + + def closeEvent(self, event): + self._showed = False + super().closeEvent(event) + + def enterEvent(self, event): + super().leaveEvent(event) + self._close_timer.stop() + + def leaveEvent(self, event): + 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() + + update_position = False + if action_id != self._current_id: + update_position = True + self._current_id = action_id + self._update_items(action_items) + + # Make sure is visible + if not self._showed: + update_position = True + self.show() + + if not update_position: + self.raise_() + return + + # Set geometry to position + # - first make sure widget changes from '_update_items' + # are recalculated + app = QtWidgets.QApplication.instance() + app.processEvents() + items_count, size, target_size = self._get_size_hint() + 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 + + 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_() + + def _on_clicked(self, index): + 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) + + def _on_expand_anim(self, value): + if not self._showed: + if self._expand_anim.state() == QtCore.QAbstractAnimation.Running: + self._expand_anim.stop() + return + + 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 + _, _, size = self._get_size_hint() + self._on_expand_anim(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 + + 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 = ( + grid_size.width() + + self._view.horizontalOffset() + border + m_l + m_r + 1 + ) + single_height = ( + grid_size.height() + + self._view.verticalOffset() + border + m_b + m_t + 1 + ) + 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 ( + cols * rows, + QtCore.QSize(single_width, single_height), + QtCore.QSize(total_width, total_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. + self._model.set_action_items(action_items) + self._view.update_on_refresh() + + def _on_trigger(self, action_id): + self.action_triggered.emit(action_id) + self.close() + + def _on_configs_trigger(self, action_id): + self.config_requested.emit(action_id) + self.close() + + class ActionDelegate(QtWidgets.QStyledItemDelegate): _cached_extender = {} + _cached_extender_base_pix = None 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) + def sizeHint(self, option, index): + return option.widget.gridSize() + + def updateEditorGeometry(self, editor, option, index): + editor.setGeometry(option.rect) + def _draw_animation(self, painter, option, index): grid_size = option.widget.gridSize() x_offset = int( @@ -244,7 +657,17 @@ def _get_extender_pixmap(cls, size): 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(64, 64) + cls._cached_extender_base_pix = base_pix + + pix = base_pix.scaled( size, size, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation @@ -260,15 +683,8 @@ def paint(self, painter, option, index): if index.data(ANIMATION_STATE_ROLE): self._draw_animation(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) + option.displayAlignment = QtCore.Qt.AlignHCenter | QtCore.Qt.AlignTop + super().paint(painter, option, index) if not index.data(ACTION_IS_GROUP_ROLE): return @@ -297,7 +713,11 @@ def __init__(self, *args, **kwargs): 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) @@ -318,29 +738,129 @@ def lessThan(self, left, right): return True +class ActionsView(QtWidgets.QListView): + action_triggered = QtCore.Signal(str) + config_requested = QtCore.Signal(str) + + def __init__(self, parent): + super().__init__(parent) + self.setProperty("mode", "icon") + self.setViewMode(QtWidgets.QListView.IconMode) + self.setResizeMode(QtWidgets.QListView.Adjust) + self.setSelectionMode(QtWidgets.QListView.NoSelection) + 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) + self.setWrapping(True) + self.setSpacing(0) + self.setWordWrap(True) + self.setMouseTracking(True) + + vertical_scroll = self.verticalScrollBar() + vertical_scroll.setSingleStep(8) + + delegate = ActionDelegate(self) + self.setItemDelegate(delegate) + + # Make view flickable + flick = FlickCharm(parent=self) + flick.activateOn(self) + + self.customContextMenuRequested.connect(self._on_context_menu) + + self._overlay_widgets = [] + self._flick = flick + self._delegate = delegate + self._popup_widget = None + + def mouseMoveEvent(self, event): + """Handle mouse move event.""" + super().mouseMoveEvent(event) + # Update hover state for the item under mouse + index = self.indexAt(event.pos()) + if index.isValid() and index.data(ACTION_IS_GROUP_ROLE): + self._show_group_popup(index) + + elif self._popup_widget is not None: + self._popup_widget.close() + + def _on_context_menu(self, point): + """Creates menu to force skip opening last workfile.""" + 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) + + popup_widget.action_triggered.connect(self.action_triggered) + popup_widget.config_requested.connect(self.config_requested) + self._popup_widget = popup_widget + return self._popup_widget + + def _show_group_popup(self, index): + action_id = index.data(ACTION_ID_ROLE) + model = self.model() + while hasattr(model, "sourceModel"): + model = model.sourceModel() + + if not hasattr(model, "get_group_items"): + return + + action_items = model.get_group_items(action_id) + rect = self.visualRect(index) + pos = self.mapToGlobal(rect.topLeft()) + + popup_widget = self._get_popup_widget() + popup_widget.show_items( + action_id, action_items, pos + ) + + 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): - 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 = ActionsView(self) 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) model = ActionsQtModel(controller) @@ -348,9 +868,6 @@ def __init__(self, controller, parent): 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) @@ -360,15 +877,13 @@ def __init__(self, controller, parent): animation_timer.timeout.connect(self._on_animation) view.clicked.connect(self._on_clicked) - view.customContextMenuRequested.connect(self._on_context_menu) + view.action_triggered.connect(self._trigger_action) + view.config_requested.connect(self._on_config_request) 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 self._proxy_model = proxy_model @@ -378,14 +893,52 @@ def __init__(self, controller, parent): 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( + WebactionContext( + identifier, + event["project_name"], + event["folder_id"], + event["task_id"], + event["addon_name"], + event["addon_version"], + ), + event["action_label"], + form_data, + ) + def _set_row_height(self, rows): self.setMinimumHeight(rows * 75) def _on_model_refresh(self): self._proxy_model.sort(0) # Force repaint all items - viewport = self._view.viewport() - viewport.update() + self._view.update_on_refresh() def _on_animation(self): time_now = time.time() @@ -416,89 +969,193 @@ 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 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) + 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() - - if not is_group: + action_item = self._model.get_action_item_by_id(action_id) + + if action_item.action_type == "webaction": + action_item = self._model.get_action_item_by_id(action_id) + context = WebactionContext( + action_id, + project_name, + folder_id, + task_id, + action_item.addon_name, + action_item.addon_version + ) + self._controller.trigger_webaction( + context, action_item.full_label + ) + else: self._controller.trigger_action( - project_name, folder_id, task_id, action_id + action_id, project_name, folder_id, task_id ) - self._start_animation(index) - return - action_items = self._model.get_group_items(action_id) + 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()) - menu = QtWidgets.QMenu(self) - actions_mapping = {} + if index is not None: + self._start_animation(index) - for action_item in action_items: - menu_action = QtWidgets.QAction(action_item.full_label) - menu.addAction(menu_action) - actions_mapping[menu_action] = action_item + def _on_config_request(self, action_id): + self._show_config_dialog(action_id) - result = menu.exec_(QtGui.QCursor.pos()) - if not result: + 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 - 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() + context = WebactionContext( + action_id, + project_name=project_name, + folder_id=folder_id, + task_id=task_id, + addon_name=action_item.addon_name, + addon_version=action_item.addon_version, + ) + values = self._controller.get_action_config_values(context) - self._controller.trigger_action( - project_name, folder_id, task_id, action_item.identifier + dialog = self._create_attrs_dialog( + config_fields, + "Action Config", + "Save", + "Cancel", + ) + dialog.set_values(values) + result = dialog.exec_() + if result == QtWidgets.QDialog.Accepted: + new_values = dialog.get_values() + self._controller.set_action_config_values(context, 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"), + minimum=config_field.get("min"), + maximum=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, ) - self._start_animation(index) + 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/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 a9617d0d19..0000000000 Binary files a/client/ayon_core/tools/launcher/ui/resources/options.png and /dev/null differ diff --git a/client/ayon_core/tools/launcher/ui/window.py b/client/ayon_core/tools/launcher/ui/window.py index aa336108ed..7fde8518b0 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 @@ def __init__(self, controller=None, parent=None): self._controller = controller + overlay_object = MessageOverlayObject(self) + # Main content - Pages & Actions content_body = QtWidgets.QSplitter(self) @@ -78,26 +80,18 @@ def __init__(self, controller=None, parent=None): 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 @@ def __init__(self, controller=None, parent=None): 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( @@ -128,6 +121,16 @@ 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._overlay_object = overlay_object self._controller = controller @@ -141,11 +144,8 @@ def __init__(self, controller=None, parent=None): 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 @@ -185,13 +185,6 @@ def _on_actions_refresh_timeout(self): 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 @@ -215,13 +208,69 @@ def _on_projects_refresh(self): 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: {}".format(event["full_label"]), + message_id=event["trigger_id"], + ) def _on_action_trigger_finished(self, event): - if not event["failed"]: + action_label = event["full_label"] + if event["failed"]: + message = f"Failed to run: {action_label}" + else: + message = f"Finished: {action_label}" + self._show_toast_message( + message, + not event["failed"], + message_id=event["trigger_id"], + ) + + def _on_webaction_trigger_started(self, event): + self._show_toast_message( + "Running: {}".format(event["full_label"]), + message_id=event["trigger_id"], + ) + + def _on_webaction_trigger_finished(self, event): + clipboard_text = event["clipboard_text"] + if clipboard_text: + clipboard = QtWidgets.QApplication.clipboard() + clipboard.setText(clipboard_text) + + action_label = event["full_label"] + # Avoid to show exception message + if event["trigger_failed"]: + self._show_toast_message( + f"Failed to run: {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._show_toast_message( + event["error_message"], + success=False, + message_id=event["trigger_id"] + ) return - self._echo("Failed: {}".format(event["error_message"])) + + if event["message"]: + self._show_toast_message(event["message"], event["success"]) + + if event["form"]: + self._actions_widget.handle_webaction_form_event(event) def _is_page_slide_anim_running(self): return ( 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..f7919a3317 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( + "Failed to download image '%s'", 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..af0745af1f 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 Exception: + markdown = None + from ayon_core.style import ( get_objected_colors, get_style_image_path, @@ -131,6 +136,37 @@ 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) + + 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): + if not self._text_format_available: + text = self._md_to_html(text) + super().setText(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. @@ -459,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: @@ -475,7 +511,7 @@ def mouseReleaseEvent(self, event): if self.rect().contains(event.pos()): self.clicked.emit() - super(ClickableLabel, self).mouseReleaseEvent(event) + super().mouseReleaseEvent(event) class ExpandBtnLabel(QtWidgets.QLabel): @@ -704,7 +740,7 @@ def _set_resized_pix(self): def resizeEvent(self, event): self._set_resized_pix() - super(PixmapLabel, self).resizeEvent(event) + super().resizeEvent(event) class PixmapButtonPainter(QtWidgets.QWidget): 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" diff --git a/package.py b/package.py index f564d371d2..908d34ffa8 100644 --- a/package.py +++ b/package.py @@ -6,11 +6,12 @@ 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 = { "ayon_ocio": ">=1.2.1", + "applications": ">=1.1.2", "harmony": ">0.4.0", "fusion": ">=0.3.3", "openrv": ">=1.0.2",