Merge pull request #1226 from ynput/feature/107-webactions-in-launcher-tool

Launcher tool: Use webactions
This commit is contained in:
Jakub Trllo 2025-06-12 15:05:13 +02:00 committed by GitHub
commit b4477649b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1705 additions and 607 deletions

View file

@ -62,6 +62,7 @@ from .execute import (
run_subprocess,
run_detached_process,
run_ayon_launcher_process,
run_detached_ayon_launcher_process,
path_to_subprocess_arg,
CREATE_NO_WINDOW
)
@ -131,6 +132,7 @@ from .ayon_info import (
is_staging_enabled,
is_dev_mode_enabled,
is_in_tests,
get_settings_variant,
)
terminal = Terminal
@ -160,6 +162,7 @@ __all__ = [
"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 @@ __all__ = [
"is_staging_enabled",
"is_dev_mode_enabled",
"is_in_tests",
"get_settings_variant",
]

View file

@ -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():

View file

@ -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", "<path to .py script>")
```
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", "<path to .py script>")
```
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", "<path to .py script>")
```
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.

View file

@ -56,14 +56,9 @@ class _AyonSettingsCache:
@classmethod
def _get_variant(cls):
if _AyonSettingsCache.variant is None:
from ayon_core.lib import is_staging_enabled, is_dev_mode_enabled
variant = "production"
if is_dev_mode_enabled():
variant = cls._get_bundle_name()
elif is_staging_enabled():
variant = "staging"
from ayon_core.lib import get_settings_variant
variant = get_settings_variant()
# Cache variant
_AyonSettingsCache.variant = variant

View file

@ -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;

View file

@ -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 @@ class AttributeDefinitionsDialog(QtWidgets.QDialog):
main_layout.addStretch(1)
main_layout.addWidget(btns_widget, 0)
ok_btn.clicked.connect(self.accept)
submit_btn.clicked.connect(self.accept)
cancel_btn.clicked.connect(self.reject)
self._attrs_widget = attrs_widget
self._submit_btn = submit_btn
self._cancel_btn = cancel_btn
def get_values(self):
return self._attrs_widget.current_value()
def set_values(self, values):
self._attrs_widget.set_value(values)
def set_submit_label(self, text: str):
self._submit_btn.setText(text)
def set_submit_icon(self, icon: QtGui.QIcon):
self._submit_btn.setIcon(icon)
def set_submit_visible(self, visible: bool):
self._submit_btn.setVisible(visible)
def set_cancel_label(self, text: str):
self._cancel_btn.setText(text)
def set_cancel_icon(self, icon: QtGui.QIcon):
self._cancel_btn.setIcon(icon)
def set_cancel_visible(self, visible: bool):
self._cancel_btn.setVisible(visible)

View file

@ -22,6 +22,7 @@ from ayon_core.tools.utils import (
FocusSpinBox,
FocusDoubleSpinBox,
MultiSelectionComboBox,
MarkdownLabel,
PlaceholderLineEdit,
PlaceholderPlainTextEdit,
set_style_property,
@ -247,12 +248,10 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget):
def set_value(self, value):
new_value = copy.deepcopy(value)
unused_keys = set(new_value.keys())
for widget in self._widgets_by_id.values():
attr_def = widget.attr_def
if attr_def.key not in new_value:
continue
unused_keys.remove(attr_def.key)
widget_value = new_value[attr_def.key]
if widget_value is None:
@ -350,7 +349,7 @@ class SeparatorAttrWidget(_BaseAttrDefWidget):
class LabelAttrWidget(_BaseAttrDefWidget):
def _ui_init(self):
input_widget = QtWidgets.QLabel(self)
input_widget = MarkdownLabel(self)
label = self.attr_def.label
if label:
input_widget.setText(str(label))

View file

@ -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 @@ class AbstractLauncherBackend(AbstractLauncherCommon):
class AbstractLauncherFrontEnd(AbstractLauncherCommon):
# Entity items for UI
@abstractmethod
def get_project_items(self, sender=None):
def get_project_items(
self, sender: Optional[str] = None
) -> list[ProjectItem]:
"""Project items for all projects.
This function may trigger events 'projects.refresh.started' and
@ -106,7 +163,9 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
pass
@abstractmethod
def get_folder_type_items(self, project_name, sender=None):
def get_folder_type_items(
self, project_name: str, sender: Optional[str] = None
) -> list[FolderTypeItem]:
"""Folder type items for a project.
This function may trigger events with topics
@ -126,7 +185,9 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
pass
@abstractmethod
def get_task_type_items(self, project_name, sender=None):
def get_task_type_items(
self, project_name: str, sender: Optional[str] = None
) -> list[TaskTypeItem]:
"""Task type items for a project.
This function may trigger events with topics
@ -146,7 +207,9 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
pass
@abstractmethod
def get_folder_items(self, project_name, sender=None):
def get_folder_items(
self, project_name: str, sender: Optional[str] = None
) -> list[FolderItem]:
"""Folder items to visualize project hierarchy.
This function may trigger events 'folders.refresh.started' and
@ -165,7 +228,9 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
pass
@abstractmethod
def get_task_items(self, project_name, folder_id, sender=None):
def get_task_items(
self, project_name: str, folder_id: str, sender: Optional[str] = None
) -> list[TaskItem]:
"""Task items.
This function may trigger events 'tasks.refresh.started' and
@ -185,7 +250,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
pass
@abstractmethod
def get_selected_project_name(self):
def get_selected_project_name(self) -> Optional[str]:
"""Selected project name.
Returns:
@ -195,7 +260,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
pass
@abstractmethod
def get_selected_folder_id(self):
def get_selected_folder_id(self) -> Optional[str]:
"""Selected folder id.
Returns:
@ -205,7 +270,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
pass
@abstractmethod
def get_selected_task_id(self):
def get_selected_task_id(self) -> Optional[str]:
"""Selected task id.
Returns:
@ -215,7 +280,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
pass
@abstractmethod
def get_selected_task_name(self):
def get_selected_task_name(self) -> Optional[str]:
"""Selected task name.
Returns:
@ -225,7 +290,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
pass
@abstractmethod
def get_selected_context(self):
def get_selected_context(self) -> dict[str, Optional[str]]:
"""Get whole selected context.
Example:
@ -243,7 +308,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
pass
@abstractmethod
def set_selected_project(self, project_name):
def set_selected_project(self, project_name: Optional[str]):
"""Change selected folder.
Args:
@ -254,7 +319,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
pass
@abstractmethod
def set_selected_folder(self, folder_id):
def set_selected_folder(self, folder_id: Optional[str]):
"""Change selected folder.
Args:
@ -265,7 +330,9 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
pass
@abstractmethod
def set_selected_task(self, task_id, task_name):
def set_selected_task(
self, task_id: Optional[str], task_name: Optional[str]
):
"""Change selected task.
Args:
@ -279,7 +346,12 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
# Actions
@abstractmethod
def get_action_items(self, project_name, folder_id, task_id):
def get_action_items(
self,
project_name: Optional[str],
folder_id: Optional[str],
task_id: Optional[str],
) -> list[ActionItem]:
"""Get action items for given context.
Args:
@ -295,30 +367,67 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
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 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
pass
@abstractmethod
def get_my_tasks_entity_ids(self, project_name: str):
def get_my_tasks_entity_ids(
self, project_name: str
) -> dict[str, list[str]]:
"""Get entity ids for my tasks.
Args:
project_name (str): Project name.
Returns:
dict[str, Union[list[str]]]: Folder and task ids.
dict[str, list[str]]: Folder and task ids.
"""
pass

View file

@ -32,7 +32,7 @@ class BaseLauncherController(
@property
def event_system(self):
"""Inner event system for workfiles tool controller.
"""Inner event system for launcher tool controller.
Is used for communication with UI. Event system is created on demand.
@ -135,16 +135,30 @@ class BaseLauncherController(
return self._actions_model.get_action_items(
project_name, folder_id, task_id)
def set_application_force_not_open_workfile(
self, project_name, folder_id, task_id, action_ids, enabled
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):

View file

@ -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)
from ayon_core.tools.launcher.abstract import ActionItem, WebactionContext
# 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)
@dataclass
class WebactionForm:
fields: list[dict[str, Any]]
title: str
submit_label: str
submit_icon: str
cancel_label: str
cancel_icon: str
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'.
"""
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
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 @@ class ActionsModel:
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 @@ class ActionsModel:
self._discovered_actions = None
self._actions = None
self._action_items = {}
self._webaction_items.reset()
self._controller.emit_event("actions.refresh.started")
self._get_action_objects()
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 @@ class ActionsModel:
Returns:
list[ActionItem]: List of actions.
"""
not_open_workfile_actions = self._get_no_last_workfile_for_context(
project_name, folder_id, task_id)
selection = self._prepare_selection(project_name, folder_id, task_id)
output = []
action_items = self._get_action_items(project_name)
for identifier, action in self._get_action_objects().items():
if not action.is_compatible(selection):
continue
if action.is_compatible(selection):
output.append(action_items[identifier])
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 @@ class ActionsModel:
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 @@ class ActionsModel:
self._controller.emit_event(
"action.trigger.finished",
{
"trigger_id": trigger_id,
"identifier": identifier,
"failed": failed,
"error_message": error_message,
@ -419,32 +196,148 @@ class ActionsModel:
}
)
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 @@ class ActionsModel:
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 @@ class ActionsModel:
register_launcher_action_path(path)
self._discovered_actions = (
discover_launcher_actions()
+ self._get_applications_action_classes()
)
return self._discovered_actions
@ -498,62 +563,29 @@ class ActionsModel:
action_items = {}
for identifier, action in self._get_action_objects().items():
is_application = isinstance(action, ApplicationAction)
# Backwards compatibility from 0.3.3 (24/06/10)
# TODO: Remove in future releases
if is_application and hasattr(action, "project_settings"):
if hasattr(action, "project_settings"):
action.project_entities[project_name] = project_entity
action.project_settings[project_name] = project_settings
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

File diff suppressed because it is too large Load diff

View file

@ -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")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -1,9 +1,9 @@
from qtpy import QtWidgets, QtCore, QtGui
from ayon_core import style
from ayon_core import resources
from ayon_core import style, resources
from ayon_core.tools.launcher.control import BaseLauncherController
from ayon_core.tools.utils import MessageOverlayObject
from .projects_widget import ProjectsWidget
from .hierarchy_page import HierarchyPage
@ -41,6 +41,8 @@ class LauncherWindow(QtWidgets.QWidget):
self._controller = controller
overlay_object = MessageOverlayObject(self)
# Main content - Pages & Actions
content_body = QtWidgets.QSplitter(self)
@ -78,26 +80,18 @@ class LauncherWindow(QtWidgets.QWidget):
content_body.setSizes([580, 160])
# Footer
footer_widget = QtWidgets.QWidget(self)
# - Message label
message_label = QtWidgets.QLabel(footer_widget)
# footer_widget = QtWidgets.QWidget(self)
#
# action_history = ActionHistory(footer_widget)
# action_history.setStatusTip("Show Action History")
footer_layout = QtWidgets.QHBoxLayout(footer_widget)
footer_layout.setContentsMargins(0, 0, 0, 0)
footer_layout.addWidget(message_label, 1)
#
# footer_layout = QtWidgets.QHBoxLayout(footer_widget)
# footer_layout.setContentsMargins(0, 0, 0, 0)
# footer_layout.addWidget(action_history, 0)
layout = QtWidgets.QVBoxLayout(self)
layout.addWidget(content_body, 1)
layout.addWidget(footer_widget, 0)
message_timer = QtCore.QTimer()
message_timer.setInterval(self.message_interval)
message_timer.setSingleShot(True)
# layout.addWidget(footer_widget, 0)
actions_refresh_timer = QtCore.QTimer()
actions_refresh_timer.setInterval(self.refresh_interval)
@ -109,7 +103,6 @@ class LauncherWindow(QtWidgets.QWidget):
page_slide_anim.setEasingCurve(QtCore.QEasingCurve.OutQuad)
projects_page.refreshed.connect(self._on_projects_refresh)
message_timer.timeout.connect(self._on_message_timeout)
actions_refresh_timer.timeout.connect(
self._on_actions_refresh_timeout)
page_slide_anim.valueChanged.connect(
@ -128,6 +121,16 @@ class LauncherWindow(QtWidgets.QWidget):
"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 @@ class LauncherWindow(QtWidgets.QWidget):
self._projects_page = projects_page
self._hierarchy_page = hierarchy_page
self._actions_widget = actions_widget
self._message_label = message_label
# self._action_history = action_history
self._message_timer = message_timer
self._actions_refresh_timer = actions_refresh_timer
self._page_slide_anim = page_slide_anim
@ -185,13 +185,6 @@ class LauncherWindow(QtWidgets.QWidget):
else:
self._refresh_on_activate = True
def _echo(self, message):
self._message_label.setText(str(message))
self._message_timer.start()
def _on_message_timeout(self):
self._message_label.setText("")
def _on_project_selection_change(self, event):
project_name = event["project_name"]
self._selected_project_name = project_name
@ -215,13 +208,69 @@ class LauncherWindow(QtWidgets.QWidget):
self._hierarchy_page.refresh()
self._actions_widget.refresh()
def _show_toast_message(self, message, success=True, message_id=None):
message_type = None
if not success:
message_type = "error"
self._overlay_object.add_message(
message, message_type, message_id=message_id
)
def _on_action_trigger_started(self, event):
self._echo("Running action: {}".format(event["full_label"]))
self._show_toast_message(
"Running: {}".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
self._echo("Failed: {}".format(event["error_message"]))
# 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
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 (

View file

@ -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

View file

@ -6,6 +6,7 @@ from .widgets import (
CustomTextComboBox,
PlaceholderLineEdit,
PlaceholderPlainTextEdit,
MarkdownLabel,
ElideLabel,
HintedLineEdit,
ExpandingTextEdit,
@ -91,6 +92,7 @@ __all__ = (
"CustomTextComboBox",
"PlaceholderLineEdit",
"PlaceholderPlainTextEdit",
"MarkdownLabel",
"ElideLabel",
"HintedLineEdit",
"ExpandingTextEdit",

View file

@ -14,3 +14,4 @@ except AttributeError:
DEFAULT_PROJECT_LABEL = "< Default >"
PROJECT_NAME_ROLE = QtCore.Qt.UserRole + 101
PROJECT_IS_ACTIVE_ROLE = QtCore.Qt.UserRole + 102
DEFAULT_WEB_ICON_COLOR = "#f4f5f5"

View file

@ -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.style import (
from ayon_core.resources import get_image_path
from ayon_core.lib import Logger
from .constants import CHECKED_INT, UNCHECKED_INT, PARTIALLY_CHECKED_INT
from .constants import (
CHECKED_INT,
UNCHECKED_INT,
PARTIALLY_CHECKED_INT,
DEFAULT_WEB_ICON_COLOR,
)
log = Logger.get_logger(__name__)
@ -480,11 +488,27 @@ class _IconsCache:
if icon_type == "path":
parts = [icon_type, icon_def["path"]]
elif icon_type in {"awesome-font", "material-symbols"}:
color = icon_def["color"] or ""
elif icon_type == "awesome-font":
color = icon_def.get("color") or ""
if isinstance(color, QtGui.QColor):
color = color.name()
parts = [icon_type, icon_def["name"] or "", color]
elif icon_type == "material-symbols":
color = icon_def.get("color") or DEFAULT_WEB_ICON_COLOR
if isinstance(color, QtGui.QColor):
color = color.name()
parts = [icon_type, icon_def["name"] or "", color]
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 @@ class _IconsCache:
elif icon_type == "awesome-font":
icon_name = icon_def["name"]
icon_color = icon_def["color"]
icon_color = icon_def.get("color")
icon = cls.get_qta_icon_by_name_and_color(icon_name, icon_color)
if icon is None:
icon = cls.get_qta_icon_by_name_and_color(
@ -513,10 +537,40 @@ class _IconsCache:
elif icon_type == "material-symbols":
icon_name = icon_def["name"]
icon_color = icon_def["color"]
icon_color = icon_def.get("color") or DEFAULT_WEB_ICON_COLOR
if qtmaterialsymbols.get_icon_name_char(icon_name) is not None:
icon = qtmaterialsymbols.get_icon(icon_name, icon_color)
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

View file

@ -6,6 +6,11 @@ from qtpy import QtWidgets, QtCore, QtGui
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 @@ class PlaceholderPlainTextEdit(QtWidgets.QPlainTextEdit):
viewport.setPalette(filter_palette)
class MarkdownLabel(QtWidgets.QLabel):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Enable word wrap by default
self.setWordWrap(True)
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 @@ class ClickableLabel(QtWidgets.QLabel):
if self.rect().contains(event.pos()):
self.clicked.emit()
super(ClickableLabel, self).mouseReleaseEvent(event)
super().mouseReleaseEvent(event)
class ExpandBtnLabel(QtWidgets.QLabel):
@ -704,7 +740,7 @@ class PixmapLabel(QtWidgets.QLabel):
def resizeEvent(self, event):
self._set_resized_pix()
super(PixmapLabel, self).resizeEvent(event)
super().resizeEvent(event)
class PixmapButtonPainter(QtWidgets.QWidget):

View file

@ -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"

View file

@ -6,11 +6,12 @@ client_dir = "ayon_core"
plugin_for = ["ayon_server"]
ayon_server_version = ">=1.7.6,<2.0.0"
ayon_server_version = ">=1.8.4,<2.0.0"
ayon_launcher_version = ">=1.0.2"
ayon_required_addons = {}
ayon_compatible_addons = {
"ayon_ocio": ">=1.2.1",
"applications": ">=1.1.2",
"harmony": ">0.4.0",
"fusion": ">=0.3.3",
"openrv": ">=1.0.2",