mirror of
https://github.com/ynput/ayon-core.git
synced 2025-12-25 05:14:40 +01:00
Launcher tool: Refactor launcher tool (for AYON) (#5612)
* added helper classes to utils * implemented base of ayon utils * initial commit for launcher tool * use image for extender * actions are shown and can be triggered * fix actions on finished refresh * refresh automatically * fix re-refreshing of projects model * added page slide animation * updated abstrack classes * change how icon is prepared * fix actions sorting * show messages like in launcher tool * do not clear items on refresh * stop refresh timer only on close event * use Ynput/AYON for local settings json * register default actions in launcher action module * change register naming * move 'SquareButton' to utils widgets * removed duplicated method * removed unused variable * removed unused import * don't use lambda * swap default name for 'OpenPypeSettingsRegistry' * Change support version
This commit is contained in:
parent
3cf203e465
commit
87ed2f960d
27 changed files with 4158 additions and 68 deletions
|
|
@ -494,10 +494,18 @@ class OpenPypeSettingsRegistry(JSONSettingRegistry):
|
|||
"""
|
||||
|
||||
def __init__(self, name=None):
|
||||
self.vendor = "pypeclub"
|
||||
self.product = "openpype"
|
||||
if AYON_SERVER_ENABLED:
|
||||
vendor = "Ynput"
|
||||
product = "AYON"
|
||||
default_name = "AYON_settings"
|
||||
else:
|
||||
vendor = "pypeclub"
|
||||
product = "openpype"
|
||||
default_name = "openpype_settings"
|
||||
self.vendor = vendor
|
||||
self.product = product
|
||||
if not name:
|
||||
name = "openpype_settings"
|
||||
name = default_name
|
||||
path = appdirs.user_data_dir(self.product, self.vendor)
|
||||
super(OpenPypeSettingsRegistry, self).__init__(name, path)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import os
|
||||
|
||||
from openpype import PLUGINS_DIR, AYON_SERVER_ENABLED
|
||||
from openpype.modules import (
|
||||
OpenPypeModule,
|
||||
ITrayAction,
|
||||
|
|
@ -13,36 +16,66 @@ class LauncherAction(OpenPypeModule, ITrayAction):
|
|||
self.enabled = True
|
||||
|
||||
# Tray attributes
|
||||
self.window = None
|
||||
self._window = None
|
||||
|
||||
def tray_init(self):
|
||||
self.create_window()
|
||||
self._create_window()
|
||||
|
||||
self.add_doubleclick_callback(self.show_launcher)
|
||||
self.add_doubleclick_callback(self._show_launcher)
|
||||
|
||||
def tray_start(self):
|
||||
return
|
||||
|
||||
def connect_with_modules(self, enabled_modules):
|
||||
# Register actions
|
||||
if self.tray_initialized:
|
||||
from openpype.tools.launcher import actions
|
||||
actions.register_config_actions()
|
||||
actions_paths = self.manager.collect_plugin_paths()["actions"]
|
||||
actions.register_actions_from_paths(actions_paths)
|
||||
actions.register_environment_actions()
|
||||
|
||||
def create_window(self):
|
||||
if self.window:
|
||||
if not self.tray_initialized:
|
||||
return
|
||||
from openpype.tools.launcher import LauncherWindow
|
||||
self.window = LauncherWindow()
|
||||
|
||||
from openpype.pipeline.actions import register_launcher_action_path
|
||||
|
||||
actions_dir = os.path.join(PLUGINS_DIR, "actions")
|
||||
if os.path.exists(actions_dir):
|
||||
register_launcher_action_path(actions_dir)
|
||||
|
||||
actions_paths = self.manager.collect_plugin_paths()["actions"]
|
||||
for path in actions_paths:
|
||||
if path and os.path.exists(path):
|
||||
register_launcher_action_path(actions_dir)
|
||||
|
||||
paths_str = os.environ.get("AVALON_ACTIONS") or ""
|
||||
if paths_str:
|
||||
self.log.warning(
|
||||
"WARNING: 'AVALON_ACTIONS' is deprecated. Support of this"
|
||||
" environment variable will be removed in future versions."
|
||||
" Please consider using 'OpenPypeModule' to define custom"
|
||||
" action paths. Planned version to drop the support"
|
||||
" is 3.17.2 or 3.18.0 ."
|
||||
)
|
||||
|
||||
for path in paths_str.split(os.pathsep):
|
||||
if path and os.path.exists(path):
|
||||
register_launcher_action_path(path)
|
||||
|
||||
def on_action_trigger(self):
|
||||
self.show_launcher()
|
||||
"""Implementation for ITrayAction interface.
|
||||
|
||||
def show_launcher(self):
|
||||
if self.window:
|
||||
self.window.show()
|
||||
self.window.raise_()
|
||||
self.window.activateWindow()
|
||||
Show launcher tool on action trigger.
|
||||
"""
|
||||
|
||||
self._show_launcher()
|
||||
|
||||
def _create_window(self):
|
||||
if self._window:
|
||||
return
|
||||
if AYON_SERVER_ENABLED:
|
||||
from openpype.tools.ayon_launcher.ui import LauncherWindow
|
||||
else:
|
||||
from openpype.tools.launcher import LauncherWindow
|
||||
self._window = LauncherWindow()
|
||||
|
||||
def _show_launcher(self):
|
||||
if self._window is None:
|
||||
return
|
||||
self._window.show()
|
||||
self._window.raise_()
|
||||
self._window.activateWindow()
|
||||
|
|
|
|||
|
|
@ -20,7 +20,13 @@ class LauncherAction(object):
|
|||
log.propagate = True
|
||||
|
||||
def is_compatible(self, session):
|
||||
"""Return whether the class is compatible with the Session."""
|
||||
"""Return whether the class is compatible with the Session.
|
||||
|
||||
Args:
|
||||
session (dict[str, Union[str, None]]): Session data with
|
||||
AVALON_PROJECT, AVALON_ASSET and AVALON_TASK.
|
||||
"""
|
||||
|
||||
return True
|
||||
|
||||
def process(self, session, **kwargs):
|
||||
|
|
|
|||
297
openpype/tools/ayon_launcher/abstract.py
Normal file
297
openpype/tools/ayon_launcher/abstract.py
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
from abc import ABCMeta, abstractmethod
|
||||
|
||||
import six
|
||||
|
||||
|
||||
@six.add_metaclass(ABCMeta)
|
||||
class AbstractLauncherCommon(object):
|
||||
@abstractmethod
|
||||
def register_event_callback(self, topic, callback):
|
||||
"""Register event callback.
|
||||
|
||||
Listen for events with given topic.
|
||||
|
||||
Args:
|
||||
topic (str): Name of topic.
|
||||
callback (Callable): Callback that will be called when event
|
||||
is triggered.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class AbstractLauncherBackend(AbstractLauncherCommon):
|
||||
@abstractmethod
|
||||
def emit_event(self, topic, data=None, source=None):
|
||||
"""Emit event.
|
||||
|
||||
Args:
|
||||
topic (str): Event topic used for callbacks filtering.
|
||||
data (Optional[dict[str, Any]]): Event data.
|
||||
source (Optional[str]): Event source.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_project_settings(self, project_name):
|
||||
"""Project settings for current project.
|
||||
|
||||
Args:
|
||||
project_name (Union[str, None]): Project name.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Project settings.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_project_entity(self, project_name):
|
||||
"""Get project entity by name.
|
||||
|
||||
Args:
|
||||
project_name (str): Project name.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Project entity data.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_folder_entity(self, project_name, folder_id):
|
||||
"""Get folder entity by id.
|
||||
|
||||
Args:
|
||||
project_name (str): Project name.
|
||||
folder_id (str): Folder id.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Folder entity data.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_task_entity(self, project_name, task_id):
|
||||
"""Get task entity by id.
|
||||
|
||||
Args:
|
||||
project_name (str): Project name.
|
||||
task_id (str): Task id.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Task entity data.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class AbstractLauncherFrontEnd(AbstractLauncherCommon):
|
||||
# Entity items for UI
|
||||
@abstractmethod
|
||||
def get_project_items(self, sender=None):
|
||||
"""Project items for all projects.
|
||||
|
||||
This function may trigger events 'projects.refresh.started' and
|
||||
'projects.refresh.finished' which will contain 'sender' value in data.
|
||||
That may help to avoid re-refresh of project items in UI elements.
|
||||
|
||||
Args:
|
||||
sender (str): Who requested folder items.
|
||||
|
||||
Returns:
|
||||
list[ProjectItem]: Minimum possible information needed
|
||||
for visualisation of folder hierarchy.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_folder_items(self, project_name, sender=None):
|
||||
"""Folder items to visualize project hierarchy.
|
||||
|
||||
This function may trigger events 'folders.refresh.started' and
|
||||
'folders.refresh.finished' which will contain 'sender' value in data.
|
||||
That may help to avoid re-refresh of folder items in UI elements.
|
||||
|
||||
Args:
|
||||
project_name (str): Project name.
|
||||
sender (str): Who requested folder items.
|
||||
|
||||
Returns:
|
||||
list[FolderItem]: Minimum possible information needed
|
||||
for visualisation of folder hierarchy.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_task_items(self, project_name, folder_id, sender=None):
|
||||
"""Task items.
|
||||
|
||||
This function may trigger events 'tasks.refresh.started' and
|
||||
'tasks.refresh.finished' which will contain 'sender' value in data.
|
||||
That may help to avoid re-refresh of task items in UI elements.
|
||||
|
||||
Args:
|
||||
project_name (str): Project name.
|
||||
folder_id (str): Folder ID for which are tasks requested.
|
||||
sender (str): Who requested folder items.
|
||||
|
||||
Returns:
|
||||
list[TaskItem]: Minimum possible information needed
|
||||
for visualisation of tasks.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_selected_project_name(self):
|
||||
"""Selected project name.
|
||||
|
||||
Returns:
|
||||
Union[str, None]: Selected project name.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_selected_folder_id(self):
|
||||
"""Selected folder id.
|
||||
|
||||
Returns:
|
||||
Union[str, None]: Selected folder id.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_selected_task_id(self):
|
||||
"""Selected task id.
|
||||
|
||||
Returns:
|
||||
Union[str, None]: Selected task id.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_selected_task_name(self):
|
||||
"""Selected task name.
|
||||
|
||||
Returns:
|
||||
Union[str, None]: Selected task name.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_selected_context(self):
|
||||
"""Get whole selected context.
|
||||
|
||||
Example:
|
||||
{
|
||||
"project_name": self.get_selected_project_name(),
|
||||
"folder_id": self.get_selected_folder_id(),
|
||||
"task_id": self.get_selected_task_id(),
|
||||
"task_name": self.get_selected_task_name(),
|
||||
}
|
||||
|
||||
Returns:
|
||||
dict[str, Union[str, None]]: Selected context.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_selected_project(self, project_name):
|
||||
"""Change selected folder.
|
||||
|
||||
Args:
|
||||
project_name (Union[str, None]): Project nameor None if no project
|
||||
is selected.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_selected_folder(self, folder_id):
|
||||
"""Change selected folder.
|
||||
|
||||
Args:
|
||||
folder_id (Union[str, None]): Folder id or None if no folder
|
||||
is selected.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_selected_task(self, task_id, task_name):
|
||||
"""Change selected task.
|
||||
|
||||
Args:
|
||||
task_id (Union[str, None]): Task id or None if no task
|
||||
is selected.
|
||||
task_name (Union[str, None]): Task name or None if no task
|
||||
is selected.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
# Actions
|
||||
@abstractmethod
|
||||
def get_action_items(self, project_name, folder_id, task_id):
|
||||
"""Get action items for given context.
|
||||
|
||||
Args:
|
||||
project_name (Union[str, None]): Project name.
|
||||
folder_id (Union[str, None]): Folder id.
|
||||
task_id (Union[str, None]): Task id.
|
||||
|
||||
Returns:
|
||||
list[ActionItem]: List of action items that should be shown
|
||||
for given context.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def trigger_action(self, project_name, folder_id, task_id, action_id):
|
||||
"""Trigger action on 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_id (str): Action identifier.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_application_force_not_open_workfile(
|
||||
self, project_name, folder_id, task_id, action_id, enabled
|
||||
):
|
||||
"""This is application action related to force not open last workfile.
|
||||
|
||||
Args:
|
||||
project_name (Union[str, None]): Project name.
|
||||
folder_id (Union[str, None]): Folder id.
|
||||
task_id (Union[str, None]): Task id.
|
||||
action_id (str): Action identifier.
|
||||
enabled (bool): New value of force not open workfile.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def refresh(self):
|
||||
"""Refresh everything, models, ui etc.
|
||||
|
||||
Triggers 'controller.refresh.started' event at the beginning and
|
||||
'controller.refresh.finished' at the end.
|
||||
"""
|
||||
|
||||
pass
|
||||
149
openpype/tools/ayon_launcher/control.py
Normal file
149
openpype/tools/ayon_launcher/control.py
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
from openpype.lib import Logger
|
||||
from openpype.lib.events import QueuedEventSystem
|
||||
from openpype.settings import get_project_settings
|
||||
from openpype.tools.ayon_utils.models import ProjectsModel, HierarchyModel
|
||||
|
||||
from .abstract import AbstractLauncherFrontEnd, AbstractLauncherBackend
|
||||
from .models import LauncherSelectionModel, ActionsModel
|
||||
|
||||
|
||||
class BaseLauncherController(
|
||||
AbstractLauncherFrontEnd, AbstractLauncherBackend
|
||||
):
|
||||
def __init__(self):
|
||||
self._project_settings = {}
|
||||
self._event_system = None
|
||||
self._log = None
|
||||
|
||||
self._selection_model = LauncherSelectionModel(self)
|
||||
self._projects_model = ProjectsModel(self)
|
||||
self._hierarchy_model = HierarchyModel(self)
|
||||
self._actions_model = ActionsModel(self)
|
||||
|
||||
@property
|
||||
def log(self):
|
||||
if self._log is None:
|
||||
self._log = Logger.get_logger(self.__class__.__name__)
|
||||
return self._log
|
||||
|
||||
@property
|
||||
def event_system(self):
|
||||
"""Inner event system for workfiles tool controller.
|
||||
|
||||
Is used for communication with UI. Event system is created on demand.
|
||||
|
||||
Returns:
|
||||
QueuedEventSystem: Event system which can trigger callbacks
|
||||
for topics.
|
||||
"""
|
||||
|
||||
if self._event_system is None:
|
||||
self._event_system = QueuedEventSystem()
|
||||
return self._event_system
|
||||
|
||||
# ---------------------------------
|
||||
# Implementation of abstract methods
|
||||
# ---------------------------------
|
||||
# Events system
|
||||
def emit_event(self, topic, data=None, source=None):
|
||||
"""Use implemented event system to trigger event."""
|
||||
|
||||
if data is None:
|
||||
data = {}
|
||||
self.event_system.emit(topic, data, source)
|
||||
|
||||
def register_event_callback(self, topic, callback):
|
||||
self.event_system.add_callback(topic, callback)
|
||||
|
||||
# Entity items for UI
|
||||
def get_project_items(self, sender=None):
|
||||
return self._projects_model.get_project_items(sender)
|
||||
|
||||
def get_folder_items(self, project_name, sender=None):
|
||||
return self._hierarchy_model.get_folder_items(project_name, sender)
|
||||
|
||||
def get_task_items(self, project_name, folder_id, sender=None):
|
||||
return self._hierarchy_model.get_task_items(
|
||||
project_name, folder_id, sender)
|
||||
|
||||
# Project settings for applications actions
|
||||
def get_project_settings(self, project_name):
|
||||
if project_name in self._project_settings:
|
||||
return self._project_settings[project_name]
|
||||
settings = get_project_settings(project_name)
|
||||
self._project_settings[project_name] = settings
|
||||
return settings
|
||||
|
||||
# Entity for backend
|
||||
def get_project_entity(self, project_name):
|
||||
return self._projects_model.get_project_entity(project_name)
|
||||
|
||||
def get_folder_entity(self, project_name, folder_id):
|
||||
return self._hierarchy_model.get_folder_entity(
|
||||
project_name, folder_id)
|
||||
|
||||
def get_task_entity(self, project_name, task_id):
|
||||
return self._hierarchy_model.get_task_entity(project_name, task_id)
|
||||
|
||||
# Selection methods
|
||||
def get_selected_project_name(self):
|
||||
return self._selection_model.get_selected_project_name()
|
||||
|
||||
def set_selected_project(self, project_name):
|
||||
self._selection_model.set_selected_project(project_name)
|
||||
|
||||
def get_selected_folder_id(self):
|
||||
return self._selection_model.get_selected_folder_id()
|
||||
|
||||
def set_selected_folder(self, folder_id):
|
||||
self._selection_model.set_selected_folder(folder_id)
|
||||
|
||||
def get_selected_task_id(self):
|
||||
return self._selection_model.get_selected_task_id()
|
||||
|
||||
def get_selected_task_name(self):
|
||||
return self._selection_model.get_selected_task_name()
|
||||
|
||||
def set_selected_task(self, task_id, task_name):
|
||||
self._selection_model.set_selected_task(task_id, task_name)
|
||||
|
||||
def get_selected_context(self):
|
||||
return {
|
||||
"project_name": self.get_selected_project_name(),
|
||||
"folder_id": self.get_selected_folder_id(),
|
||||
"task_id": self.get_selected_task_id(),
|
||||
"task_name": self.get_selected_task_name(),
|
||||
}
|
||||
|
||||
# Actions
|
||||
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_id, enabled
|
||||
):
|
||||
self._actions_model.set_application_force_not_open_workfile(
|
||||
project_name, folder_id, task_id, action_id, enabled
|
||||
)
|
||||
|
||||
def trigger_action(self, project_name, folder_id, task_id, identifier):
|
||||
self._actions_model.trigger_action(
|
||||
project_name, folder_id, task_id, identifier)
|
||||
|
||||
# General methods
|
||||
def refresh(self):
|
||||
self._emit_event("controller.refresh.started")
|
||||
|
||||
self._project_settings = {}
|
||||
|
||||
self._projects_model.reset()
|
||||
self._hierarchy_model.reset()
|
||||
|
||||
self._actions_model.refresh()
|
||||
self._projects_model.refresh()
|
||||
|
||||
self._emit_event("controller.refresh.finished")
|
||||
|
||||
def _emit_event(self, topic, data=None):
|
||||
self.emit_event(topic, data, "controller")
|
||||
8
openpype/tools/ayon_launcher/models/__init__.py
Normal file
8
openpype/tools/ayon_launcher/models/__init__.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
from .actions import ActionsModel
|
||||
from .selection import LauncherSelectionModel
|
||||
|
||||
|
||||
__all__ = (
|
||||
"ActionsModel",
|
||||
"LauncherSelectionModel",
|
||||
)
|
||||
505
openpype/tools/ayon_launcher/models/actions.py
Normal file
505
openpype/tools/ayon_launcher/models/actions.py
Normal file
|
|
@ -0,0 +1,505 @@
|
|||
import os
|
||||
|
||||
from openpype import resources
|
||||
from openpype.lib import Logger, OpenPypeSettingsRegistry
|
||||
from openpype.pipeline.actions import (
|
||||
discover_launcher_actions,
|
||||
LauncherAction,
|
||||
)
|
||||
|
||||
|
||||
# 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 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 OpenPype 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
|
||||
required_session_keys = (
|
||||
"AVALON_PROJECT",
|
||||
"AVALON_ASSET",
|
||||
"AVALON_TASK"
|
||||
)
|
||||
|
||||
@property
|
||||
def log(self):
|
||||
if self._log is None:
|
||||
self._log = Logger.get_logger(self.__class__.__name__)
|
||||
return self._log
|
||||
|
||||
def is_compatible(self, session):
|
||||
for key in self.required_session_keys:
|
||||
if not session.get(key):
|
||||
return False
|
||||
|
||||
project_name = session["AVALON_PROJECT"]
|
||||
project_entity = self.project_entities[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[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 openpype import style
|
||||
|
||||
dialog = QtWidgets.QMessageBox()
|
||||
icon = QtGui.QIcon(resources.get_openpype_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, session, **kwargs):
|
||||
"""Process the full Application action"""
|
||||
|
||||
from openpype.lib import (
|
||||
ApplictionExecutableNotFound,
|
||||
ApplicationLaunchFailed,
|
||||
)
|
||||
|
||||
project_name = session["AVALON_PROJECT"]
|
||||
asset_name = session["AVALON_ASSET"]
|
||||
task_name = session["AVALON_TASK"]
|
||||
try:
|
||||
self.application.launch(
|
||||
project_name=project_name,
|
||||
asset_name=asset_name,
|
||||
task_name=task_name,
|
||||
**self.data
|
||||
)
|
||||
|
||||
except ApplictionExecutableNotFound 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 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)
|
||||
|
||||
|
||||
def get_action_icon(action):
|
||||
"""Get action icon info.
|
||||
|
||||
Args:
|
||||
action (LacunherAction): Action instance.
|
||||
|
||||
Returns:
|
||||
dict[str, str]: Icon info.
|
||||
"""
|
||||
|
||||
icon = action.icon
|
||||
if not icon:
|
||||
return {
|
||||
"type": "awesome-font",
|
||||
"name": "fa.cube",
|
||||
"color": "white"
|
||||
}
|
||||
|
||||
if isinstance(icon, dict):
|
||||
return icon
|
||||
|
||||
icon_path = resources.get_resource(icon)
|
||||
if not os.path.exists(icon_path):
|
||||
try:
|
||||
icon_path = icon.format(resources.RESOURCES_DIR)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if os.path.exists(icon_path):
|
||||
return {
|
||||
"type": "path",
|
||||
"path": icon_path,
|
||||
}
|
||||
|
||||
return {
|
||||
"type": "awesome-font",
|
||||
"name": icon,
|
||||
"color": action.color or "white"
|
||||
}
|
||||
|
||||
|
||||
class ActionsModel:
|
||||
"""Actions model.
|
||||
|
||||
Args:
|
||||
controller (AbstractLauncherBackend): Controller instance.
|
||||
"""
|
||||
|
||||
_not_open_workfile_reg_key = "force_not_open_workfile"
|
||||
|
||||
def __init__(self, controller):
|
||||
self._controller = controller
|
||||
|
||||
self._log = None
|
||||
|
||||
self._discovered_actions = None
|
||||
self._actions = None
|
||||
self._action_items = {}
|
||||
|
||||
self._launcher_tool_reg = OpenPypeSettingsRegistry("launcher_tool")
|
||||
|
||||
@property
|
||||
def log(self):
|
||||
if self._log is None:
|
||||
self._log = Logger.get_logger(self.__class__.__name__)
|
||||
return self._log
|
||||
|
||||
def refresh(self):
|
||||
self._discovered_actions = None
|
||||
self._actions = None
|
||||
self._action_items = {}
|
||||
|
||||
self._controller.emit_event("actions.refresh.started")
|
||||
self._get_action_objects()
|
||||
self._controller.emit_event("actions.refresh.finished")
|
||||
|
||||
def get_action_items(self, project_name, folder_id, task_id):
|
||||
"""Get actions for project.
|
||||
|
||||
Args:
|
||||
project_name (Union[str, None]): Project name.
|
||||
folder_id (Union[str, None]): Folder id.
|
||||
task_id (Union[str, None]): 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)
|
||||
session = self._prepare_session(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(session):
|
||||
continue
|
||||
|
||||
action_item = action_items[identifier]
|
||||
# Handling of 'force_not_open_workfile' for applications
|
||||
if action_item.is_application:
|
||||
action_item = action_item.copy()
|
||||
action_item.force_not_open_workfile = (
|
||||
not_open_workfile_actions.get(identifier, False)
|
||||
)
|
||||
|
||||
output.append(action_item)
|
||||
return output
|
||||
|
||||
def set_application_force_not_open_workfile(
|
||||
self, project_name, folder_id, task_id, action_id, enabled
|
||||
):
|
||||
no_workfile_reg_data = self._get_no_last_workfile_reg_data()
|
||||
project_data = no_workfile_reg_data.setdefault(project_name, {})
|
||||
folder_data = project_data.setdefault(folder_id, {})
|
||||
task_data = folder_data.setdefault(task_id, {})
|
||||
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):
|
||||
session = self._prepare_session(project_name, folder_id, task_id)
|
||||
failed = False
|
||||
error_message = None
|
||||
action_label = identifier
|
||||
action_items = self._get_action_items(project_name)
|
||||
try:
|
||||
action = self._actions[identifier]
|
||||
action_item = action_items[identifier]
|
||||
action_label = action_item.full_label
|
||||
self._controller.emit_event(
|
||||
"action.trigger.started",
|
||||
{
|
||||
"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
|
||||
)
|
||||
force_not_open_workfile = per_action.get(identifier, False)
|
||||
action.data["start_last_workfile"] = force_not_open_workfile
|
||||
action.process(session)
|
||||
except Exception as exc:
|
||||
self.log.warning("Action trigger failed.", exc_info=True)
|
||||
failed = True
|
||||
error_message = str(exc)
|
||||
|
||||
self._controller.emit_event(
|
||||
"action.trigger.finished",
|
||||
{
|
||||
"identifier": identifier,
|
||||
"failed": failed,
|
||||
"error_message": error_message,
|
||||
"full_label": action_label,
|
||||
}
|
||||
)
|
||||
|
||||
def _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_session(self, project_name, folder_id, task_id):
|
||||
folder_name = None
|
||||
if folder_id:
|
||||
folder = self._controller.get_folder_entity(
|
||||
project_name, folder_id)
|
||||
if folder:
|
||||
folder_name = folder["name"]
|
||||
|
||||
task_name = None
|
||||
if task_id:
|
||||
task = self._controller.get_task_entity(project_name, task_id)
|
||||
if task:
|
||||
task_name = task["name"]
|
||||
|
||||
return {
|
||||
"AVALON_PROJECT": project_name,
|
||||
"AVALON_ASSET": folder_name,
|
||||
"AVALON_TASK": task_name,
|
||||
}
|
||||
|
||||
def _get_discovered_action_classes(self):
|
||||
if self._discovered_actions is None:
|
||||
self._discovered_actions = (
|
||||
discover_launcher_actions()
|
||||
+ self._get_applications_action_classes()
|
||||
)
|
||||
return self._discovered_actions
|
||||
|
||||
def _get_action_objects(self):
|
||||
if self._actions is None:
|
||||
actions = {}
|
||||
for cls in self._get_discovered_action_classes():
|
||||
obj = cls()
|
||||
identifier = getattr(obj, "identifier", None)
|
||||
if identifier is None:
|
||||
identifier = cls.__name__
|
||||
actions[identifier] = obj
|
||||
self._actions = actions
|
||||
return self._actions
|
||||
|
||||
def _get_action_items(self, project_name):
|
||||
action_items = self._action_items.get(project_name)
|
||||
if action_items is not None:
|
||||
return action_items
|
||||
|
||||
project_entity = None
|
||||
if project_name:
|
||||
project_entity = self._controller.get_project_entity(project_name)
|
||||
project_settings = self._controller.get_project_settings(project_name)
|
||||
|
||||
action_items = {}
|
||||
for identifier, action in self._get_action_objects().items():
|
||||
is_application = isinstance(action, ApplicationAction)
|
||||
if is_application:
|
||||
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)
|
||||
icon = get_action_icon(action)
|
||||
item = ActionItem(
|
||||
identifier,
|
||||
label,
|
||||
variant_label,
|
||||
icon,
|
||||
action.order,
|
||||
is_application,
|
||||
False
|
||||
)
|
||||
action_items[identifier] = item
|
||||
self._action_items[project_name] = action_items
|
||||
return action_items
|
||||
|
||||
def _get_applications_action_classes(self):
|
||||
from openpype.lib.applications import (
|
||||
CUSTOM_LAUNCH_APP_GROUPS,
|
||||
ApplicationManager,
|
||||
)
|
||||
|
||||
actions = []
|
||||
|
||||
manager = ApplicationManager()
|
||||
for full_name, application in manager.applications.items():
|
||||
if (
|
||||
application.group.name in CUSTOM_LAUNCH_APP_GROUPS
|
||||
or 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
|
||||
72
openpype/tools/ayon_launcher/models/selection.py
Normal file
72
openpype/tools/ayon_launcher/models/selection.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
class LauncherSelectionModel(object):
|
||||
"""Model handling selection changes.
|
||||
|
||||
Triggering events:
|
||||
- "selection.project.changed"
|
||||
- "selection.folder.changed"
|
||||
- "selection.task.changed"
|
||||
"""
|
||||
|
||||
event_source = "launcher.selection.model"
|
||||
|
||||
def __init__(self, controller):
|
||||
self._controller = controller
|
||||
|
||||
self._project_name = None
|
||||
self._folder_id = None
|
||||
self._task_name = None
|
||||
self._task_id = None
|
||||
|
||||
def get_selected_project_name(self):
|
||||
return self._project_name
|
||||
|
||||
def set_selected_project(self, project_name):
|
||||
if project_name == self._project_name:
|
||||
return
|
||||
|
||||
self._project_name = project_name
|
||||
self._controller.emit_event(
|
||||
"selection.project.changed",
|
||||
{"project_name": project_name},
|
||||
self.event_source
|
||||
)
|
||||
|
||||
def get_selected_folder_id(self):
|
||||
return self._folder_id
|
||||
|
||||
def set_selected_folder(self, folder_id):
|
||||
if folder_id == self._folder_id:
|
||||
return
|
||||
|
||||
self._folder_id = folder_id
|
||||
self._controller.emit_event(
|
||||
"selection.folder.changed",
|
||||
{
|
||||
"project_name": self._project_name,
|
||||
"folder_id": folder_id,
|
||||
},
|
||||
self.event_source
|
||||
)
|
||||
|
||||
def get_selected_task_name(self):
|
||||
return self._task_name
|
||||
|
||||
def get_selected_task_id(self):
|
||||
return self._task_id
|
||||
|
||||
def set_selected_task(self, task_id, task_name):
|
||||
if task_id == self._task_id:
|
||||
return
|
||||
|
||||
self._task_name = task_name
|
||||
self._task_id = task_id
|
||||
self._controller.emit_event(
|
||||
"selection.task.changed",
|
||||
{
|
||||
"project_name": self._project_name,
|
||||
"folder_id": self._folder_id,
|
||||
"task_name": task_name,
|
||||
"task_id": task_id,
|
||||
},
|
||||
self.event_source
|
||||
)
|
||||
6
openpype/tools/ayon_launcher/ui/__init__.py
Normal file
6
openpype/tools/ayon_launcher/ui/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from .window import LauncherWindow
|
||||
|
||||
|
||||
__all__ = (
|
||||
"LauncherWindow",
|
||||
)
|
||||
453
openpype/tools/ayon_launcher/ui/actions_widget.py
Normal file
453
openpype/tools/ayon_launcher/ui/actions_widget.py
Normal file
|
|
@ -0,0 +1,453 @@
|
|||
import time
|
||||
import collections
|
||||
|
||||
from qtpy import QtWidgets, QtCore, QtGui
|
||||
|
||||
from openpype.tools.flickcharm import FlickCharm
|
||||
from openpype.tools.ayon_utils.widgets import get_qt_icon
|
||||
|
||||
from .resources import get_options_image_path
|
||||
|
||||
ANIMATION_LEN = 7
|
||||
|
||||
ACTION_ID_ROLE = QtCore.Qt.UserRole + 1
|
||||
ACTION_IS_APPLICATION_ROLE = QtCore.Qt.UserRole + 2
|
||||
ACTION_IS_GROUP_ROLE = QtCore.Qt.UserRole + 3
|
||||
ACTION_SORT_ROLE = QtCore.Qt.UserRole + 4
|
||||
ANIMATION_START_ROLE = QtCore.Qt.UserRole + 5
|
||||
ANIMATION_STATE_ROLE = QtCore.Qt.UserRole + 6
|
||||
FORCE_NOT_OPEN_WORKFILE_ROLE = QtCore.Qt.UserRole + 7
|
||||
|
||||
|
||||
class ActionsQtModel(QtGui.QStandardItemModel):
|
||||
"""Qt model for actions.
|
||||
|
||||
Args:
|
||||
controller (AbstractLauncherFrontEnd): Controller instance.
|
||||
"""
|
||||
|
||||
refreshed = QtCore.Signal()
|
||||
|
||||
def __init__(self, controller):
|
||||
super(ActionsQtModel, self).__init__()
|
||||
|
||||
controller.register_event_callback(
|
||||
"controller.refresh.finished",
|
||||
self._on_controller_refresh_finished,
|
||||
)
|
||||
controller.register_event_callback(
|
||||
"selection.project.changed",
|
||||
self._on_selection_project_changed,
|
||||
)
|
||||
controller.register_event_callback(
|
||||
"selection.folder.changed",
|
||||
self._on_selection_folder_changed,
|
||||
)
|
||||
controller.register_event_callback(
|
||||
"selection.task.changed",
|
||||
self._on_selection_task_changed,
|
||||
)
|
||||
|
||||
self._controller = controller
|
||||
|
||||
self._items_by_id = {}
|
||||
self._groups_by_id = {}
|
||||
|
||||
self._selected_project_name = None
|
||||
self._selected_folder_id = None
|
||||
self._selected_task_id = None
|
||||
|
||||
def get_selected_project_name(self):
|
||||
return self._selected_project_name
|
||||
|
||||
def get_selected_folder_id(self):
|
||||
return self._selected_folder_id
|
||||
|
||||
def get_selected_task_id(self):
|
||||
return self._selected_task_id
|
||||
|
||||
def get_group_items(self, action_id):
|
||||
return self._groups_by_id[action_id]
|
||||
|
||||
def get_item_by_id(self, action_id):
|
||||
return self._items_by_id.get(action_id)
|
||||
|
||||
def _clear_items(self):
|
||||
self._items_by_id = {}
|
||||
self._groups_by_id = {}
|
||||
root = self.invisibleRootItem()
|
||||
root.removeRows(0, root.rowCount())
|
||||
|
||||
def refresh(self):
|
||||
items = self._controller.get_action_items(
|
||||
self._selected_project_name,
|
||||
self._selected_folder_id,
|
||||
self._selected_task_id,
|
||||
)
|
||||
if not items:
|
||||
self._clear_items()
|
||||
self.refreshed.emit()
|
||||
return
|
||||
|
||||
root_item = self.invisibleRootItem()
|
||||
|
||||
all_action_items_info = []
|
||||
items_by_label = collections.defaultdict(list)
|
||||
for item in items:
|
||||
if not item.variant_label:
|
||||
all_action_items_info.append((item, False))
|
||||
else:
|
||||
items_by_label[item.label].append(item)
|
||||
|
||||
groups_by_id = {}
|
||||
for action_items in items_by_label.values():
|
||||
first_item = next(iter(action_items))
|
||||
all_action_items_info.append((first_item, len(action_items) > 1))
|
||||
groups_by_id[first_item.identifier] = action_items
|
||||
|
||||
new_items = []
|
||||
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)
|
||||
if is_group:
|
||||
label = action_item.label
|
||||
else:
|
||||
label = action_item.full_label
|
||||
|
||||
item = self._items_by_id.get(action_item.identifier)
|
||||
if item is None:
|
||||
item = QtGui.QStandardItem()
|
||||
item.setData(action_item.identifier, ACTION_ID_ROLE)
|
||||
new_items.append(item)
|
||||
|
||||
item.setFlags(QtCore.Qt.ItemIsEnabled)
|
||||
item.setData(label, QtCore.Qt.DisplayRole)
|
||||
item.setData(icon, QtCore.Qt.DecorationRole)
|
||||
item.setData(is_group, ACTION_IS_GROUP_ROLE)
|
||||
item.setData(action_item.order, ACTION_SORT_ROLE)
|
||||
item.setData(
|
||||
action_item.is_application, ACTION_IS_APPLICATION_ROLE)
|
||||
item.setData(
|
||||
action_item.force_not_open_workfile,
|
||||
FORCE_NOT_OPEN_WORKFILE_ROLE)
|
||||
items_by_id[action_item.identifier] = item
|
||||
|
||||
if new_items:
|
||||
root_item.appendRows(new_items)
|
||||
|
||||
to_remove = set(self._items_by_id.keys()) - set(items_by_id.keys())
|
||||
for identifier in to_remove:
|
||||
item = self._items_by_id.pop(identifier)
|
||||
root_item.removeRow(item.row())
|
||||
|
||||
self._groups_by_id = groups_by_id
|
||||
self._items_by_id = items_by_id
|
||||
self.refreshed.emit()
|
||||
|
||||
def _on_controller_refresh_finished(self):
|
||||
context = self._controller.get_selected_context()
|
||||
self._selected_project_name = context["project_name"]
|
||||
self._selected_folder_id = context["folder_id"]
|
||||
self._selected_task_id = context["task_id"]
|
||||
self.refresh()
|
||||
|
||||
def _on_selection_project_changed(self, event):
|
||||
self._selected_project_name = event["project_name"]
|
||||
self._selected_folder_id = None
|
||||
self._selected_task_id = None
|
||||
self.refresh()
|
||||
|
||||
def _on_selection_folder_changed(self, event):
|
||||
self._selected_project_name = event["project_name"]
|
||||
self._selected_folder_id = event["folder_id"]
|
||||
self._selected_task_id = None
|
||||
self.refresh()
|
||||
|
||||
def _on_selection_task_changed(self, event):
|
||||
self._selected_project_name = event["project_name"]
|
||||
self._selected_folder_id = event["folder_id"]
|
||||
self._selected_task_id = event["task_id"]
|
||||
self.refresh()
|
||||
|
||||
|
||||
class ActionDelegate(QtWidgets.QStyledItemDelegate):
|
||||
_cached_extender = {}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(ActionDelegate, self).__init__(*args, **kwargs)
|
||||
self._anim_start_color = QtGui.QColor(178, 255, 246)
|
||||
self._anim_end_color = QtGui.QColor(5, 44, 50)
|
||||
|
||||
def _draw_animation(self, painter, option, index):
|
||||
grid_size = option.widget.gridSize()
|
||||
x_offset = int(
|
||||
(grid_size.width() / 2)
|
||||
- (option.rect.width() / 2)
|
||||
)
|
||||
item_x = option.rect.x() - x_offset
|
||||
rect_offset = grid_size.width() / 20
|
||||
size = grid_size.width() - (rect_offset * 2)
|
||||
anim_rect = QtCore.QRect(
|
||||
item_x + rect_offset,
|
||||
option.rect.y() + rect_offset,
|
||||
size,
|
||||
size
|
||||
)
|
||||
|
||||
painter.save()
|
||||
|
||||
painter.setBrush(QtCore.Qt.transparent)
|
||||
|
||||
gradient = QtGui.QConicalGradient()
|
||||
gradient.setCenter(QtCore.QPointF(anim_rect.center()))
|
||||
gradient.setColorAt(0, self._anim_start_color)
|
||||
gradient.setColorAt(1, self._anim_end_color)
|
||||
|
||||
time_diff = time.time() - index.data(ANIMATION_START_ROLE)
|
||||
|
||||
# Repeat 4 times
|
||||
part_anim = 2.5
|
||||
part_time = time_diff % part_anim
|
||||
offset = (part_time / part_anim) * 360
|
||||
angle = (offset + 90) % 360
|
||||
|
||||
gradient.setAngle(-angle)
|
||||
|
||||
pen = QtGui.QPen(QtGui.QBrush(gradient), rect_offset)
|
||||
pen.setCapStyle(QtCore.Qt.RoundCap)
|
||||
painter.setPen(pen)
|
||||
painter.drawArc(
|
||||
anim_rect,
|
||||
-16 * (angle + 10),
|
||||
-16 * offset
|
||||
)
|
||||
|
||||
painter.restore()
|
||||
|
||||
@classmethod
|
||||
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(
|
||||
size, size,
|
||||
QtCore.Qt.KeepAspectRatio,
|
||||
QtCore.Qt.SmoothTransformation
|
||||
)
|
||||
cls._cached_extender[size] = pix
|
||||
return pix
|
||||
|
||||
def paint(self, painter, option, index):
|
||||
painter.setRenderHints(
|
||||
QtGui.QPainter.Antialiasing
|
||||
| QtGui.QPainter.SmoothPixmapTransform
|
||||
)
|
||||
|
||||
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.height(), 5, 5)
|
||||
painter.setPen(QtCore.Qt.NoPen)
|
||||
painter.setBrush(QtGui.QColor(200, 0, 0))
|
||||
painter.drawEllipse(rect)
|
||||
|
||||
if not index.data(ACTION_IS_GROUP_ROLE):
|
||||
return
|
||||
|
||||
grid_size = option.widget.gridSize()
|
||||
x_offset = int(
|
||||
(grid_size.width() / 2)
|
||||
- (option.rect.width() / 2)
|
||||
)
|
||||
item_x = option.rect.x() - x_offset
|
||||
|
||||
tenth_size = int(grid_size.width() / 10)
|
||||
extender_size = int(tenth_size * 2.4)
|
||||
|
||||
extender_x = item_x + tenth_size
|
||||
extender_y = option.rect.y() + tenth_size
|
||||
|
||||
pix = self._get_extender_pixmap(extender_size)
|
||||
painter.drawPixmap(extender_x, extender_y, pix)
|
||||
|
||||
|
||||
class ActionsWidget(QtWidgets.QWidget):
|
||||
def __init__(self, controller, parent):
|
||||
super(ActionsWidget, self).__init__(parent)
|
||||
|
||||
self._controller = controller
|
||||
|
||||
view = QtWidgets.QListView(self)
|
||||
view.setProperty("mode", "icon")
|
||||
view.setObjectName("IconView")
|
||||
view.setViewMode(QtWidgets.QListView.IconMode)
|
||||
view.setResizeMode(QtWidgets.QListView.Adjust)
|
||||
view.setSelectionMode(QtWidgets.QListView.NoSelection)
|
||||
view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
|
||||
view.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers)
|
||||
view.setWrapping(True)
|
||||
view.setGridSize(QtCore.QSize(70, 75))
|
||||
view.setIconSize(QtCore.QSize(30, 30))
|
||||
view.setSpacing(0)
|
||||
view.setWordWrap(True)
|
||||
|
||||
# Make view flickable
|
||||
flick = FlickCharm(parent=view)
|
||||
flick.activateOn(view)
|
||||
|
||||
model = ActionsQtModel(controller)
|
||||
|
||||
proxy_model = QtCore.QSortFilterProxyModel()
|
||||
proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive)
|
||||
proxy_model.setSortRole(ACTION_SORT_ROLE)
|
||||
|
||||
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)
|
||||
|
||||
animation_timer = QtCore.QTimer()
|
||||
animation_timer.setInterval(40)
|
||||
animation_timer.timeout.connect(self._on_animation)
|
||||
|
||||
view.clicked.connect(self._on_clicked)
|
||||
view.customContextMenuRequested.connect(self._on_context_menu)
|
||||
model.refreshed.connect(self._on_model_refresh)
|
||||
|
||||
self._animated_items = set()
|
||||
self._animation_timer = animation_timer
|
||||
|
||||
self._context_menu = None
|
||||
|
||||
self._flick = flick
|
||||
self._view = view
|
||||
self._model = model
|
||||
self._proxy_model = proxy_model
|
||||
|
||||
self._set_row_height(1)
|
||||
|
||||
def _set_row_height(self, rows):
|
||||
self.setMinimumHeight(rows * 75)
|
||||
|
||||
def _on_model_refresh(self):
|
||||
self._proxy_model.sort(0)
|
||||
|
||||
def _on_animation(self):
|
||||
time_now = time.time()
|
||||
for action_id in tuple(self._animated_items):
|
||||
item = self._model.get_item_by_id(action_id)
|
||||
if item is None:
|
||||
self._animated_items.discard(action_id)
|
||||
continue
|
||||
|
||||
start_time = item.data(ANIMATION_START_ROLE)
|
||||
if start_time is None or (time_now - start_time) > ANIMATION_LEN:
|
||||
item.setData(0, ANIMATION_STATE_ROLE)
|
||||
self._animated_items.discard(action_id)
|
||||
|
||||
if not self._animated_items:
|
||||
self._animation_timer.stop()
|
||||
|
||||
self.update()
|
||||
|
||||
def _start_animation(self, index):
|
||||
# Offset refresh timout
|
||||
model_index = self._proxy_model.mapToSource(index)
|
||||
if not model_index.isValid():
|
||||
return
|
||||
action_id = model_index.data(ACTION_ID_ROLE)
|
||||
self._model.setData(model_index, time.time(), ANIMATION_START_ROLE)
|
||||
self._model.setData(model_index, 1, ANIMATION_STATE_ROLE)
|
||||
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)
|
||||
checkbox.stateChanged.connect(
|
||||
lambda: self._on_checkbox_changed(
|
||||
action_id, 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_id, 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_id, 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)
|
||||
action_id = index.data(ACTION_ID_ROLE)
|
||||
|
||||
project_name = self._model.get_selected_project_name()
|
||||
folder_id = self._model.get_selected_folder_id()
|
||||
task_id = self._model.get_selected_task_id()
|
||||
|
||||
if not is_group:
|
||||
self._controller.trigger_action(
|
||||
project_name, folder_id, task_id, action_id
|
||||
)
|
||||
self._start_animation(index)
|
||||
return
|
||||
|
||||
action_items = self._model.get_group_items(action_id)
|
||||
|
||||
menu = QtWidgets.QMenu(self)
|
||||
actions_mapping = {}
|
||||
|
||||
for action_item in action_items:
|
||||
menu_action = QtWidgets.QAction(action_item.full_label)
|
||||
menu.addAction(menu_action)
|
||||
actions_mapping[menu_action] = action_item
|
||||
|
||||
result = menu.exec_(QtGui.QCursor.pos())
|
||||
if not result:
|
||||
return
|
||||
|
||||
action_item = actions_mapping[result]
|
||||
|
||||
self._controller.trigger_action(
|
||||
project_name, folder_id, task_id, action_item.identifier
|
||||
)
|
||||
self._start_animation(index)
|
||||
102
openpype/tools/ayon_launcher/ui/hierarchy_page.py
Normal file
102
openpype/tools/ayon_launcher/ui/hierarchy_page.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import qtawesome
|
||||
from qtpy import QtWidgets, QtCore
|
||||
|
||||
from openpype.tools.utils import (
|
||||
PlaceholderLineEdit,
|
||||
SquareButton,
|
||||
RefreshButton,
|
||||
)
|
||||
from openpype.tools.ayon_utils.widgets import (
|
||||
ProjectsCombobox,
|
||||
FoldersWidget,
|
||||
TasksWidget,
|
||||
)
|
||||
|
||||
|
||||
class HierarchyPage(QtWidgets.QWidget):
|
||||
def __init__(self, controller, parent):
|
||||
super(HierarchyPage, self).__init__(parent)
|
||||
|
||||
# Header
|
||||
header_widget = QtWidgets.QWidget(self)
|
||||
|
||||
btn_back_icon = qtawesome.icon("fa.angle-left", color="white")
|
||||
btn_back = SquareButton(header_widget)
|
||||
btn_back.setIcon(btn_back_icon)
|
||||
|
||||
projects_combobox = ProjectsCombobox(controller, header_widget)
|
||||
|
||||
refresh_btn = RefreshButton(header_widget)
|
||||
|
||||
header_layout = QtWidgets.QHBoxLayout(header_widget)
|
||||
header_layout.setContentsMargins(0, 0, 0, 0)
|
||||
header_layout.addWidget(btn_back, 0)
|
||||
header_layout.addWidget(projects_combobox, 1)
|
||||
header_layout.addWidget(refresh_btn, 0)
|
||||
|
||||
# Body - Folders + Tasks selection
|
||||
content_body = QtWidgets.QSplitter(self)
|
||||
content_body.setContentsMargins(0, 0, 0, 0)
|
||||
content_body.setSizePolicy(
|
||||
QtWidgets.QSizePolicy.Expanding,
|
||||
QtWidgets.QSizePolicy.Expanding
|
||||
)
|
||||
content_body.setOrientation(QtCore.Qt.Horizontal)
|
||||
|
||||
# - Folders widget with filter
|
||||
folders_wrapper = QtWidgets.QWidget(content_body)
|
||||
|
||||
folders_filter_text = PlaceholderLineEdit(folders_wrapper)
|
||||
folders_filter_text.setPlaceholderText("Filter folders...")
|
||||
|
||||
folders_widget = FoldersWidget(controller, folders_wrapper)
|
||||
|
||||
folders_wrapper_layout = QtWidgets.QVBoxLayout(folders_wrapper)
|
||||
folders_wrapper_layout.setContentsMargins(0, 0, 0, 0)
|
||||
folders_wrapper_layout.addWidget(folders_filter_text, 0)
|
||||
folders_wrapper_layout.addWidget(folders_widget, 1)
|
||||
|
||||
# - Tasks widget
|
||||
tasks_widget = TasksWidget(controller, content_body)
|
||||
|
||||
content_body.addWidget(folders_wrapper)
|
||||
content_body.addWidget(tasks_widget)
|
||||
content_body.setStretchFactor(0, 100)
|
||||
content_body.setStretchFactor(1, 65)
|
||||
|
||||
main_layout = QtWidgets.QVBoxLayout(self)
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
main_layout.addWidget(header_widget, 0)
|
||||
main_layout.addWidget(content_body, 1)
|
||||
|
||||
btn_back.clicked.connect(self._on_back_clicked)
|
||||
refresh_btn.clicked.connect(self._on_refreh_clicked)
|
||||
folders_filter_text.textChanged.connect(self._on_filter_text_changed)
|
||||
|
||||
self._is_visible = False
|
||||
self._controller = controller
|
||||
|
||||
self._btn_back = btn_back
|
||||
self._projects_combobox = projects_combobox
|
||||
self._folders_widget = folders_widget
|
||||
self._tasks_widget = tasks_widget
|
||||
|
||||
# Post init
|
||||
projects_combobox.set_listen_to_selection_change(self._is_visible)
|
||||
|
||||
def set_page_visible(self, visible, project_name=None):
|
||||
if self._is_visible == visible:
|
||||
return
|
||||
self._is_visible = visible
|
||||
self._projects_combobox.set_listen_to_selection_change(visible)
|
||||
if visible and project_name:
|
||||
self._projects_combobox.set_selection(project_name)
|
||||
|
||||
def _on_back_clicked(self):
|
||||
self._controller.set_selected_project(None)
|
||||
|
||||
def _on_refreh_clicked(self):
|
||||
self._controller.refresh()
|
||||
|
||||
def _on_filter_text_changed(self, text):
|
||||
self._folders_widget.set_name_filer(text)
|
||||
135
openpype/tools/ayon_launcher/ui/projects_widget.py
Normal file
135
openpype/tools/ayon_launcher/ui/projects_widget.py
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
from qtpy import QtWidgets, QtCore
|
||||
|
||||
from openpype.tools.flickcharm import FlickCharm
|
||||
from openpype.tools.utils import PlaceholderLineEdit, RefreshButton
|
||||
from openpype.tools.ayon_utils.widgets import (
|
||||
ProjectsModel,
|
||||
ProjectSortFilterProxy,
|
||||
)
|
||||
from openpype.tools.ayon_utils.models import PROJECTS_MODEL_SENDER
|
||||
|
||||
|
||||
class ProjectIconView(QtWidgets.QListView):
|
||||
"""Styled ListView that allows to toggle between icon and list mode.
|
||||
|
||||
Toggling between the two modes is done by Right Mouse Click.
|
||||
"""
|
||||
|
||||
IconMode = 0
|
||||
ListMode = 1
|
||||
|
||||
def __init__(self, parent=None, mode=ListMode):
|
||||
super(ProjectIconView, self).__init__(parent=parent)
|
||||
|
||||
# Workaround for scrolling being super slow or fast when
|
||||
# toggling between the two visual modes
|
||||
self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel)
|
||||
self.setObjectName("IconView")
|
||||
|
||||
self._mode = None
|
||||
self.set_mode(mode)
|
||||
|
||||
def set_mode(self, mode):
|
||||
if mode == self._mode:
|
||||
return
|
||||
|
||||
self._mode = mode
|
||||
|
||||
if mode == self.IconMode:
|
||||
self.setViewMode(QtWidgets.QListView.IconMode)
|
||||
self.setResizeMode(QtWidgets.QListView.Adjust)
|
||||
self.setWrapping(True)
|
||||
self.setWordWrap(True)
|
||||
self.setGridSize(QtCore.QSize(151, 90))
|
||||
self.setIconSize(QtCore.QSize(50, 50))
|
||||
self.setSpacing(0)
|
||||
self.setAlternatingRowColors(False)
|
||||
|
||||
self.setProperty("mode", "icon")
|
||||
self.style().polish(self)
|
||||
|
||||
self.verticalScrollBar().setSingleStep(30)
|
||||
|
||||
elif self.ListMode:
|
||||
self.setProperty("mode", "list")
|
||||
self.style().polish(self)
|
||||
|
||||
self.setViewMode(QtWidgets.QListView.ListMode)
|
||||
self.setResizeMode(QtWidgets.QListView.Adjust)
|
||||
self.setWrapping(False)
|
||||
self.setWordWrap(False)
|
||||
self.setIconSize(QtCore.QSize(20, 20))
|
||||
self.setGridSize(QtCore.QSize(100, 25))
|
||||
self.setSpacing(0)
|
||||
self.setAlternatingRowColors(False)
|
||||
|
||||
self.verticalScrollBar().setSingleStep(34)
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() == QtCore.Qt.RightButton:
|
||||
self.set_mode(int(not self._mode))
|
||||
return super(ProjectIconView, self).mousePressEvent(event)
|
||||
|
||||
|
||||
class ProjectsWidget(QtWidgets.QWidget):
|
||||
"""Projects Page"""
|
||||
def __init__(self, controller, parent=None):
|
||||
super(ProjectsWidget, self).__init__(parent=parent)
|
||||
|
||||
header_widget = QtWidgets.QWidget(self)
|
||||
|
||||
projects_filter_text = PlaceholderLineEdit(header_widget)
|
||||
projects_filter_text.setPlaceholderText("Filter projects...")
|
||||
|
||||
refresh_btn = RefreshButton(header_widget)
|
||||
|
||||
header_layout = QtWidgets.QHBoxLayout(header_widget)
|
||||
header_layout.setContentsMargins(0, 0, 0, 0)
|
||||
header_layout.addWidget(projects_filter_text, 1)
|
||||
header_layout.addWidget(refresh_btn, 0)
|
||||
|
||||
projects_view = ProjectIconView(parent=self)
|
||||
projects_view.setSelectionMode(QtWidgets.QListView.NoSelection)
|
||||
flick = FlickCharm(parent=self)
|
||||
flick.activateOn(projects_view)
|
||||
projects_model = ProjectsModel(controller)
|
||||
projects_proxy_model = ProjectSortFilterProxy()
|
||||
projects_proxy_model.setSourceModel(projects_model)
|
||||
|
||||
projects_view.setModel(projects_proxy_model)
|
||||
|
||||
main_layout = QtWidgets.QVBoxLayout(self)
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
main_layout.addWidget(header_widget, 0)
|
||||
main_layout.addWidget(projects_view, 1)
|
||||
|
||||
projects_view.clicked.connect(self._on_view_clicked)
|
||||
projects_filter_text.textChanged.connect(
|
||||
self._on_project_filter_change)
|
||||
refresh_btn.clicked.connect(self._on_refresh_clicked)
|
||||
|
||||
controller.register_event_callback(
|
||||
"projects.refresh.finished",
|
||||
self._on_projects_refresh_finished
|
||||
)
|
||||
|
||||
self._controller = controller
|
||||
|
||||
self._projects_view = projects_view
|
||||
self._projects_model = projects_model
|
||||
self._projects_proxy_model = projects_proxy_model
|
||||
|
||||
def _on_view_clicked(self, index):
|
||||
if index.isValid():
|
||||
project_name = index.data(QtCore.Qt.DisplayRole)
|
||||
self._controller.set_selected_project(project_name)
|
||||
|
||||
def _on_project_filter_change(self, text):
|
||||
self._projects_proxy_model.setFilterFixedString(text)
|
||||
|
||||
def _on_refresh_clicked(self):
|
||||
self._controller.refresh()
|
||||
|
||||
def _on_projects_refresh_finished(self, event):
|
||||
if event["sender"] != PROJECTS_MODEL_SENDER:
|
||||
self._projects_model.refresh()
|
||||
7
openpype/tools/ayon_launcher/ui/resources/__init__.py
Normal file
7
openpype/tools/ayon_launcher/ui/resources/__init__.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import os
|
||||
|
||||
RESOURCES_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def get_options_image_path():
|
||||
return os.path.join(RESOURCES_DIR, "options.png")
|
||||
BIN
openpype/tools/ayon_launcher/ui/resources/options.png
Normal file
BIN
openpype/tools/ayon_launcher/ui/resources/options.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
295
openpype/tools/ayon_launcher/ui/window.py
Normal file
295
openpype/tools/ayon_launcher/ui/window.py
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
from qtpy import QtWidgets, QtCore, QtGui
|
||||
|
||||
from openpype import style
|
||||
from openpype import resources
|
||||
|
||||
from openpype.tools.ayon_launcher.control import BaseLauncherController
|
||||
|
||||
from .projects_widget import ProjectsWidget
|
||||
from .hierarchy_page import HierarchyPage
|
||||
from .actions_widget import ActionsWidget
|
||||
|
||||
|
||||
class LauncherWindow(QtWidgets.QWidget):
|
||||
"""Launcher interface"""
|
||||
message_interval = 5000
|
||||
refresh_interval = 10000
|
||||
page_side_anim_interval = 250
|
||||
|
||||
def __init__(self, controller=None, parent=None):
|
||||
super(LauncherWindow, self).__init__(parent)
|
||||
|
||||
if controller is None:
|
||||
controller = BaseLauncherController()
|
||||
|
||||
icon = QtGui.QIcon(resources.get_openpype_icon_filepath())
|
||||
self.setWindowIcon(icon)
|
||||
self.setWindowTitle("Launcher")
|
||||
self.setFocusPolicy(QtCore.Qt.StrongFocus)
|
||||
self.setAttribute(QtCore.Qt.WA_DeleteOnClose, False)
|
||||
|
||||
self.setStyleSheet(style.load_stylesheet())
|
||||
|
||||
# Allow minimize
|
||||
self.setWindowFlags(
|
||||
QtCore.Qt.Window
|
||||
| QtCore.Qt.CustomizeWindowHint
|
||||
| QtCore.Qt.WindowTitleHint
|
||||
| QtCore.Qt.WindowMinimizeButtonHint
|
||||
| QtCore.Qt.WindowCloseButtonHint
|
||||
)
|
||||
|
||||
self._controller = controller
|
||||
|
||||
# Main content - Pages & Actions
|
||||
content_body = QtWidgets.QSplitter(self)
|
||||
|
||||
# Pages
|
||||
pages_widget = QtWidgets.QWidget(content_body)
|
||||
|
||||
# - First page - Projects
|
||||
projects_page = ProjectsWidget(controller, pages_widget)
|
||||
|
||||
# - Second page - Hierarchy (folders & tasks)
|
||||
hierarchy_page = HierarchyPage(controller, pages_widget)
|
||||
|
||||
pages_layout = QtWidgets.QHBoxLayout(pages_widget)
|
||||
pages_layout.setContentsMargins(0, 0, 0, 0)
|
||||
pages_layout.addWidget(projects_page, 1)
|
||||
pages_layout.addWidget(hierarchy_page, 1)
|
||||
|
||||
# Actions
|
||||
actions_widget = ActionsWidget(controller, content_body)
|
||||
|
||||
# Vertically split Pages and Actions
|
||||
content_body.setContentsMargins(0, 0, 0, 0)
|
||||
content_body.setSizePolicy(
|
||||
QtWidgets.QSizePolicy.Expanding,
|
||||
QtWidgets.QSizePolicy.Expanding
|
||||
)
|
||||
content_body.setOrientation(QtCore.Qt.Vertical)
|
||||
content_body.addWidget(pages_widget)
|
||||
content_body.addWidget(actions_widget)
|
||||
|
||||
# Set useful default sizes and set stretch
|
||||
# for the pages so that is the only one that
|
||||
# stretches on UI resize.
|
||||
content_body.setStretchFactor(0, 10)
|
||||
content_body.setSizes([580, 160])
|
||||
|
||||
# Footer
|
||||
footer_widget = QtWidgets.QWidget(self)
|
||||
|
||||
# - Message label
|
||||
message_label = QtWidgets.QLabel(footer_widget)
|
||||
|
||||
# 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.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)
|
||||
|
||||
refresh_timer = QtCore.QTimer()
|
||||
refresh_timer.setInterval(self.refresh_interval)
|
||||
|
||||
page_slide_anim = QtCore.QVariantAnimation(self)
|
||||
page_slide_anim.setDuration(self.page_side_anim_interval)
|
||||
page_slide_anim.setStartValue(0.0)
|
||||
page_slide_anim.setEndValue(1.0)
|
||||
page_slide_anim.setEasingCurve(QtCore.QEasingCurve.OutQuad)
|
||||
|
||||
message_timer.timeout.connect(self._on_message_timeout)
|
||||
refresh_timer.timeout.connect(self._on_refresh_timeout)
|
||||
page_slide_anim.valueChanged.connect(
|
||||
self._on_page_slide_value_changed)
|
||||
page_slide_anim.finished.connect(self._on_page_slide_finished)
|
||||
|
||||
controller.register_event_callback(
|
||||
"selection.project.changed",
|
||||
self._on_project_selection_change,
|
||||
)
|
||||
controller.register_event_callback(
|
||||
"action.trigger.started",
|
||||
self._on_action_trigger_started,
|
||||
)
|
||||
controller.register_event_callback(
|
||||
"action.trigger.finished",
|
||||
self._on_action_trigger_finished,
|
||||
)
|
||||
|
||||
self._controller = controller
|
||||
|
||||
self._is_on_projects_page = True
|
||||
self._window_is_active = False
|
||||
self._refresh_on_activate = False
|
||||
|
||||
self._pages_widget = pages_widget
|
||||
self._pages_layout = pages_layout
|
||||
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._refresh_timer = refresh_timer
|
||||
self._page_slide_anim = page_slide_anim
|
||||
|
||||
hierarchy_page.setVisible(not self._is_on_projects_page)
|
||||
self.resize(520, 740)
|
||||
|
||||
def showEvent(self, event):
|
||||
super(LauncherWindow, self).showEvent(event)
|
||||
self._window_is_active = True
|
||||
if not self._refresh_timer.isActive():
|
||||
self._refresh_timer.start()
|
||||
self._controller.refresh()
|
||||
|
||||
def closeEvent(self, event):
|
||||
super(LauncherWindow, self).closeEvent(event)
|
||||
self._window_is_active = False
|
||||
self._refresh_timer.stop()
|
||||
|
||||
def changeEvent(self, event):
|
||||
if event.type() in (
|
||||
QtCore.QEvent.Type.WindowStateChange,
|
||||
QtCore.QEvent.ActivationChange,
|
||||
):
|
||||
is_active = self.isActiveWindow() and not self.isMinimized()
|
||||
self._window_is_active = is_active
|
||||
if is_active and self._refresh_on_activate:
|
||||
self._refresh_on_activate = False
|
||||
self._on_refresh_timeout()
|
||||
self._refresh_timer.start()
|
||||
|
||||
super(LauncherWindow, self).changeEvent(event)
|
||||
|
||||
def _on_refresh_timeout(self):
|
||||
# Stop timer if widget is not visible
|
||||
if self._window_is_active:
|
||||
self._controller.refresh()
|
||||
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"]
|
||||
if not project_name:
|
||||
self._go_to_projects_page()
|
||||
|
||||
elif self._is_on_projects_page:
|
||||
self._go_to_hierarchy_page(project_name)
|
||||
|
||||
def _on_action_trigger_started(self, event):
|
||||
self._echo("Running action: {}".format(event["full_label"]))
|
||||
|
||||
def _on_action_trigger_finished(self, event):
|
||||
if not event["failed"]:
|
||||
return
|
||||
self._echo("Failed: {}".format(event["error_message"]))
|
||||
|
||||
def _is_page_slide_anim_running(self):
|
||||
return (
|
||||
self._page_slide_anim.state() == QtCore.QAbstractAnimation.Running
|
||||
)
|
||||
|
||||
def _go_to_projects_page(self):
|
||||
if self._is_on_projects_page:
|
||||
return
|
||||
self._is_on_projects_page = True
|
||||
self._hierarchy_page.set_page_visible(False)
|
||||
|
||||
self._start_page_slide_animation()
|
||||
|
||||
def _go_to_hierarchy_page(self, project_name):
|
||||
if not self._is_on_projects_page:
|
||||
return
|
||||
self._is_on_projects_page = False
|
||||
self._hierarchy_page.set_page_visible(True, project_name)
|
||||
|
||||
self._start_page_slide_animation()
|
||||
|
||||
def _start_page_slide_animation(self):
|
||||
if self._is_on_projects_page:
|
||||
direction = QtCore.QAbstractAnimation.Backward
|
||||
else:
|
||||
direction = QtCore.QAbstractAnimation.Forward
|
||||
self._page_slide_anim.setDirection(direction)
|
||||
if self._is_page_slide_anim_running():
|
||||
return
|
||||
|
||||
layout_spacing = self._pages_layout.spacing()
|
||||
if self._is_on_projects_page:
|
||||
hierarchy_geo = self._hierarchy_page.geometry()
|
||||
projects_geo = QtCore.QRect(hierarchy_geo)
|
||||
projects_geo.moveRight(
|
||||
hierarchy_geo.left() - (layout_spacing + 1))
|
||||
|
||||
self._projects_page.setVisible(True)
|
||||
|
||||
else:
|
||||
projects_geo = self._projects_page.geometry()
|
||||
hierarchy_geo = QtCore.QRect(projects_geo)
|
||||
hierarchy_geo.moveLeft(projects_geo.right() + layout_spacing)
|
||||
self._hierarchy_page.setVisible(True)
|
||||
|
||||
while self._pages_layout.count():
|
||||
self._pages_layout.takeAt(0)
|
||||
|
||||
self._projects_page.setGeometry(projects_geo)
|
||||
self._hierarchy_page.setGeometry(hierarchy_geo)
|
||||
|
||||
self._page_slide_anim.start()
|
||||
|
||||
def _on_page_slide_value_changed(self, value):
|
||||
layout_spacing = self._pages_layout.spacing()
|
||||
content_width = self._pages_widget.width() - layout_spacing
|
||||
content_height = self._pages_widget.height()
|
||||
|
||||
# Visible widths of other widgets
|
||||
hierarchy_width = int(content_width * value)
|
||||
|
||||
hierarchy_geo = QtCore.QRect(
|
||||
content_width - hierarchy_width, 0, content_width, content_height
|
||||
)
|
||||
projects_geo = QtCore.QRect(hierarchy_geo)
|
||||
projects_geo.moveRight(hierarchy_geo.left() - (layout_spacing + 1))
|
||||
|
||||
self._projects_page.setGeometry(projects_geo)
|
||||
self._hierarchy_page.setGeometry(hierarchy_geo)
|
||||
|
||||
def _on_page_slide_finished(self):
|
||||
self._pages_layout.addWidget(self._projects_page, 1)
|
||||
self._pages_layout.addWidget(self._hierarchy_page, 1)
|
||||
self._projects_page.setVisible(self._is_on_projects_page)
|
||||
self._hierarchy_page.setVisible(not self._is_on_projects_page)
|
||||
|
||||
# def _on_history_action(self, history_data):
|
||||
# action, session = history_data
|
||||
# app = QtWidgets.QApplication.instance()
|
||||
# modifiers = app.keyboardModifiers()
|
||||
#
|
||||
# is_control_down = QtCore.Qt.ControlModifier & modifiers
|
||||
# if is_control_down:
|
||||
# # Revert to that "session" location
|
||||
# self.set_session(session)
|
||||
# else:
|
||||
# # User is holding control, rerun the action
|
||||
# self.run_action(action, session=session)
|
||||
29
openpype/tools/ayon_utils/models/__init__.py
Normal file
29
openpype/tools/ayon_utils/models/__init__.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"""Backend models that can be used in controllers."""
|
||||
|
||||
from .cache import CacheItem, NestedCacheItem
|
||||
from .projects import (
|
||||
ProjectItem,
|
||||
ProjectsModel,
|
||||
PROJECTS_MODEL_SENDER,
|
||||
)
|
||||
from .hierarchy import (
|
||||
FolderItem,
|
||||
TaskItem,
|
||||
HierarchyModel,
|
||||
HIERARCHY_MODEL_SENDER,
|
||||
)
|
||||
|
||||
|
||||
__all__ = (
|
||||
"CacheItem",
|
||||
"NestedCacheItem",
|
||||
|
||||
"ProjectItem",
|
||||
"ProjectsModel",
|
||||
"PROJECTS_MODEL_SENDER",
|
||||
|
||||
"FolderItem",
|
||||
"TaskItem",
|
||||
"HierarchyModel",
|
||||
"HIERARCHY_MODEL_SENDER",
|
||||
)
|
||||
196
openpype/tools/ayon_utils/models/cache.py
Normal file
196
openpype/tools/ayon_utils/models/cache.py
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
import time
|
||||
import collections
|
||||
|
||||
InitInfo = collections.namedtuple(
|
||||
"InitInfo",
|
||||
["default_factory", "lifetime"]
|
||||
)
|
||||
|
||||
|
||||
def _default_factory_func():
|
||||
return None
|
||||
|
||||
|
||||
class CacheItem:
|
||||
"""Simple cache item with lifetime and default value.
|
||||
|
||||
Args:
|
||||
default_factory (Optional[callable]): Function that returns default
|
||||
value used on init and on reset.
|
||||
lifetime (Optional[int]): Lifetime of the cache data in seconds.
|
||||
"""
|
||||
|
||||
def __init__(self, default_factory=None, lifetime=None):
|
||||
if lifetime is None:
|
||||
lifetime = 120
|
||||
self._lifetime = lifetime
|
||||
self._last_update = None
|
||||
if default_factory is None:
|
||||
default_factory = _default_factory_func
|
||||
self._default_factory = default_factory
|
||||
self._data = default_factory()
|
||||
|
||||
@property
|
||||
def is_valid(self):
|
||||
"""Is cache valid to use.
|
||||
|
||||
Return:
|
||||
bool: True if cache is valid, False otherwise.
|
||||
"""
|
||||
|
||||
if self._last_update is None:
|
||||
return False
|
||||
|
||||
return (time.time() - self._last_update) < self._lifetime
|
||||
|
||||
def set_lifetime(self, lifetime):
|
||||
"""Change lifetime of cache item.
|
||||
|
||||
Args:
|
||||
lifetime (int): Lifetime of the cache data in seconds.
|
||||
"""
|
||||
|
||||
self._lifetime = lifetime
|
||||
|
||||
def set_invalid(self):
|
||||
"""Set cache as invalid."""
|
||||
|
||||
self._last_update = None
|
||||
|
||||
def reset(self):
|
||||
"""Set cache as invalid and reset data."""
|
||||
|
||||
self._last_update = None
|
||||
self._data = self._default_factory()
|
||||
|
||||
def get_data(self):
|
||||
"""Receive cached data.
|
||||
|
||||
Returns:
|
||||
Any: Any data that are cached.
|
||||
"""
|
||||
|
||||
return self._data
|
||||
|
||||
def update_data(self, data):
|
||||
self._data = data
|
||||
self._last_update = time.time()
|
||||
|
||||
|
||||
class NestedCacheItem:
|
||||
"""Helper for cached items stored in nested structure.
|
||||
|
||||
Example:
|
||||
>>> cache = NestedCacheItem(levels=2)
|
||||
>>> cache["a"]["b"].is_valid
|
||||
False
|
||||
>>> cache["a"]["b"].get_data()
|
||||
None
|
||||
>>> cache["a"]["b"] = 1
|
||||
>>> cache["a"]["b"].is_valid
|
||||
True
|
||||
>>> cache["a"]["b"].get_data()
|
||||
1
|
||||
>>> cache.reset()
|
||||
>>> cache["a"]["b"].is_valid
|
||||
False
|
||||
|
||||
Args:
|
||||
levels (int): Number of nested levels where read cache is stored.
|
||||
default_factory (Optional[callable]): Function that returns default
|
||||
value used on init and on reset.
|
||||
lifetime (Optional[int]): Lifetime of the cache data in seconds.
|
||||
_init_info (Optional[InitInfo]): Private argument. Init info for
|
||||
nested cache where created from parent item.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, levels=1, default_factory=None, lifetime=None, _init_info=None
|
||||
):
|
||||
if levels < 1:
|
||||
raise ValueError("Nested levels must be greater than 0")
|
||||
self._data_by_key = {}
|
||||
if _init_info is None:
|
||||
_init_info = InitInfo(default_factory, lifetime)
|
||||
self._init_info = _init_info
|
||||
self._levels = levels
|
||||
|
||||
def __getitem__(self, key):
|
||||
"""Get cached data.
|
||||
|
||||
Args:
|
||||
key (str): Key of the cache item.
|
||||
|
||||
Returns:
|
||||
Union[NestedCacheItem, CacheItem]: Cache item.
|
||||
"""
|
||||
|
||||
cache = self._data_by_key.get(key)
|
||||
if cache is None:
|
||||
if self._levels > 1:
|
||||
cache = NestedCacheItem(
|
||||
levels=self._levels - 1,
|
||||
_init_info=self._init_info
|
||||
)
|
||||
else:
|
||||
cache = CacheItem(
|
||||
self._init_info.default_factory,
|
||||
self._init_info.lifetime
|
||||
)
|
||||
self._data_by_key[key] = cache
|
||||
return cache
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
"""Update cached data.
|
||||
|
||||
Args:
|
||||
key (str): Key of the cache item.
|
||||
value (Any): Any data that are cached.
|
||||
"""
|
||||
|
||||
if self._levels > 1:
|
||||
raise AttributeError((
|
||||
"{} does not support '__setitem__'. Lower nested level by {}"
|
||||
).format(self.__class__.__name__, self._levels - 1))
|
||||
cache = self[key]
|
||||
cache.update_data(value)
|
||||
|
||||
def get(self, key):
|
||||
"""Get cached data.
|
||||
|
||||
Args:
|
||||
key (str): Key of the cache item.
|
||||
|
||||
Returns:
|
||||
Union[NestedCacheItem, CacheItem]: Cache item.
|
||||
"""
|
||||
|
||||
return self[key]
|
||||
|
||||
def reset(self):
|
||||
"""Reset cache."""
|
||||
|
||||
self._data_by_key = {}
|
||||
|
||||
def set_lifetime(self, lifetime):
|
||||
"""Change lifetime of all children cache items.
|
||||
|
||||
Args:
|
||||
lifetime (int): Lifetime of the cache data in seconds.
|
||||
"""
|
||||
|
||||
self._init_info.lifetime = lifetime
|
||||
for cache in self._data_by_key.values():
|
||||
cache.set_lifetime(lifetime)
|
||||
|
||||
@property
|
||||
def is_valid(self):
|
||||
"""Raise reasonable error when called on wront level.
|
||||
|
||||
Raises:
|
||||
AttributeError: If called on nested cache item.
|
||||
"""
|
||||
|
||||
raise AttributeError((
|
||||
"{} does not support 'is_valid'. Lower nested level by '{}'"
|
||||
).format(self.__class__.__name__, self._levels))
|
||||
340
openpype/tools/ayon_utils/models/hierarchy.py
Normal file
340
openpype/tools/ayon_utils/models/hierarchy.py
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
import collections
|
||||
import contextlib
|
||||
from abc import ABCMeta, abstractmethod
|
||||
|
||||
import ayon_api
|
||||
import six
|
||||
|
||||
from openpype.style import get_default_entity_icon_color
|
||||
|
||||
from .cache import NestedCacheItem
|
||||
|
||||
HIERARCHY_MODEL_SENDER = "hierarchy.model"
|
||||
|
||||
|
||||
@six.add_metaclass(ABCMeta)
|
||||
class AbstractHierarchyController:
|
||||
@abstractmethod
|
||||
def emit_event(self, topic, data, source):
|
||||
pass
|
||||
|
||||
|
||||
class FolderItem:
|
||||
"""Item representing folder entity on a server.
|
||||
|
||||
Folder can be a child of another folder or a project.
|
||||
|
||||
Args:
|
||||
entity_id (str): Folder id.
|
||||
parent_id (Union[str, None]): Parent folder id. If 'None' then project
|
||||
is parent.
|
||||
name (str): Name of folder.
|
||||
label (str): Folder label.
|
||||
icon_name (str): Name of icon from font awesome.
|
||||
icon_color (str): Hex color string that will be used for icon.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, entity_id, parent_id, name, label, icon
|
||||
):
|
||||
self.entity_id = entity_id
|
||||
self.parent_id = parent_id
|
||||
self.name = name
|
||||
if not icon:
|
||||
icon = {
|
||||
"type": "awesome-font",
|
||||
"name": "fa.folder",
|
||||
"color": get_default_entity_icon_color()
|
||||
}
|
||||
self.icon = icon
|
||||
self.label = label or name
|
||||
|
||||
def to_data(self):
|
||||
"""Converts folder item to data.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Folder item data.
|
||||
"""
|
||||
|
||||
return {
|
||||
"entity_id": self.entity_id,
|
||||
"parent_id": self.parent_id,
|
||||
"name": self.name,
|
||||
"label": self.label,
|
||||
"icon": self.icon,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data):
|
||||
"""Re-creates folder item from data.
|
||||
|
||||
Args:
|
||||
data (dict[str, Any]): Folder item data.
|
||||
|
||||
Returns:
|
||||
FolderItem: Folder item.
|
||||
"""
|
||||
|
||||
return cls(**data)
|
||||
|
||||
|
||||
class TaskItem:
|
||||
"""Task item representing task entity on a server.
|
||||
|
||||
Task is child of a folder.
|
||||
|
||||
Task item has label that is used for display in UI. The label is by
|
||||
default using task name and type.
|
||||
|
||||
Args:
|
||||
task_id (str): Task id.
|
||||
name (str): Name of task.
|
||||
task_type (str): Type of task.
|
||||
parent_id (str): Parent folder id.
|
||||
icon_name (str): Name of icon from font awesome.
|
||||
icon_color (str): Hex color string that will be used for icon.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, task_id, name, task_type, parent_id, icon
|
||||
):
|
||||
self.task_id = task_id
|
||||
self.name = name
|
||||
self.task_type = task_type
|
||||
self.parent_id = parent_id
|
||||
if icon is None:
|
||||
icon = {
|
||||
"type": "awesome-font",
|
||||
"name": "fa.male",
|
||||
"color": get_default_entity_icon_color()
|
||||
}
|
||||
self.icon = icon
|
||||
|
||||
self._label = None
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
"""Alias for task_id.
|
||||
|
||||
Returns:
|
||||
str: Task id.
|
||||
"""
|
||||
|
||||
return self.task_id
|
||||
|
||||
@property
|
||||
def label(self):
|
||||
"""Label of task item for UI.
|
||||
|
||||
Returns:
|
||||
str: Label of task item.
|
||||
"""
|
||||
|
||||
if self._label is None:
|
||||
self._label = "{} ({})".format(self.name, self.task_type)
|
||||
return self._label
|
||||
|
||||
def to_data(self):
|
||||
"""Converts task item to data.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Task item data.
|
||||
"""
|
||||
|
||||
return {
|
||||
"task_id": self.task_id,
|
||||
"name": self.name,
|
||||
"parent_id": self.parent_id,
|
||||
"task_type": self.task_type,
|
||||
"icon": self.icon,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data):
|
||||
"""Re-create task item from data.
|
||||
|
||||
Args:
|
||||
data (dict[str, Any]): Task item data.
|
||||
|
||||
Returns:
|
||||
TaskItem: Task item.
|
||||
"""
|
||||
|
||||
return cls(**data)
|
||||
|
||||
|
||||
def _get_task_items_from_tasks(tasks):
|
||||
"""
|
||||
|
||||
Returns:
|
||||
TaskItem: Task item.
|
||||
"""
|
||||
|
||||
output = []
|
||||
for task in tasks:
|
||||
folder_id = task["folderId"]
|
||||
output.append(TaskItem(
|
||||
task["id"],
|
||||
task["name"],
|
||||
task["type"],
|
||||
folder_id,
|
||||
None
|
||||
))
|
||||
return output
|
||||
|
||||
|
||||
def _get_folder_item_from_hierarchy_item(item):
|
||||
return FolderItem(
|
||||
item["id"],
|
||||
item["parentId"],
|
||||
item["name"],
|
||||
item["label"],
|
||||
None
|
||||
)
|
||||
|
||||
|
||||
class HierarchyModel(object):
|
||||
"""Model for project hierarchy items.
|
||||
|
||||
Hierarchy items are folders and tasks. Folders can have as parent another
|
||||
folder or project. Tasks can have as parent only folder.
|
||||
"""
|
||||
|
||||
def __init__(self, controller):
|
||||
self._folders_items = NestedCacheItem(levels=1, default_factory=dict)
|
||||
self._folders_by_id = NestedCacheItem(levels=2, default_factory=dict)
|
||||
|
||||
self._task_items = NestedCacheItem(levels=2, default_factory=dict)
|
||||
self._tasks_by_id = NestedCacheItem(levels=2, default_factory=dict)
|
||||
|
||||
self._folders_refreshing = set()
|
||||
self._tasks_refreshing = set()
|
||||
self._controller = controller
|
||||
|
||||
def reset(self):
|
||||
self._folders_items.reset()
|
||||
self._folders_by_id.reset()
|
||||
|
||||
self._task_items.reset()
|
||||
self._tasks_by_id.reset()
|
||||
|
||||
def refresh_project(self, project_name):
|
||||
self._refresh_folders_cache(project_name)
|
||||
|
||||
def get_folder_items(self, project_name, sender):
|
||||
if not self._folders_items[project_name].is_valid:
|
||||
self._refresh_folders_cache(project_name, sender)
|
||||
return self._folders_items[project_name].get_data()
|
||||
|
||||
def get_task_items(self, project_name, folder_id, sender):
|
||||
if not project_name or not folder_id:
|
||||
return []
|
||||
|
||||
task_cache = self._task_items[project_name][folder_id]
|
||||
if not task_cache.is_valid:
|
||||
self._refresh_tasks_cache(project_name, folder_id, sender)
|
||||
return task_cache.get_data()
|
||||
|
||||
def get_folder_entity(self, project_name, folder_id):
|
||||
cache = self._folders_by_id[project_name][folder_id]
|
||||
if not cache.is_valid:
|
||||
entity = None
|
||||
if folder_id:
|
||||
entity = ayon_api.get_folder_by_id(project_name, folder_id)
|
||||
cache.update_data(entity)
|
||||
return cache.get_data()
|
||||
|
||||
def get_task_entity(self, project_name, task_id):
|
||||
cache = self._tasks_by_id[project_name][task_id]
|
||||
if not cache.is_valid:
|
||||
entity = None
|
||||
if task_id:
|
||||
entity = ayon_api.get_task_by_id(project_name, task_id)
|
||||
cache.update_data(entity)
|
||||
return cache.get_data()
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _folder_refresh_event_manager(self, project_name, sender):
|
||||
self._folders_refreshing.add(project_name)
|
||||
self._controller.emit_event(
|
||||
"folders.refresh.started",
|
||||
{"project_name": project_name, "sender": sender},
|
||||
HIERARCHY_MODEL_SENDER
|
||||
)
|
||||
try:
|
||||
yield
|
||||
|
||||
finally:
|
||||
self._controller.emit_event(
|
||||
"folders.refresh.finished",
|
||||
{"project_name": project_name, "sender": sender},
|
||||
HIERARCHY_MODEL_SENDER
|
||||
)
|
||||
self._folders_refreshing.remove(project_name)
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _task_refresh_event_manager(
|
||||
self, project_name, folder_id, sender
|
||||
):
|
||||
self._tasks_refreshing.add(folder_id)
|
||||
self._controller.emit_event(
|
||||
"tasks.refresh.started",
|
||||
{
|
||||
"project_name": project_name,
|
||||
"folder_id": folder_id,
|
||||
"sender": sender,
|
||||
},
|
||||
HIERARCHY_MODEL_SENDER
|
||||
)
|
||||
try:
|
||||
yield
|
||||
|
||||
finally:
|
||||
self._controller.emit_event(
|
||||
"tasks.refresh.finished",
|
||||
{
|
||||
"project_name": project_name,
|
||||
"folder_id": folder_id,
|
||||
"sender": sender,
|
||||
},
|
||||
HIERARCHY_MODEL_SENDER
|
||||
)
|
||||
self._tasks_refreshing.discard(folder_id)
|
||||
|
||||
def _refresh_folders_cache(self, project_name, sender=None):
|
||||
if project_name in self._folders_refreshing:
|
||||
return
|
||||
|
||||
with self._folder_refresh_event_manager(project_name, sender):
|
||||
folder_items = self._query_folders(project_name)
|
||||
self._folders_items[project_name].update_data(folder_items)
|
||||
|
||||
def _query_folders(self, project_name):
|
||||
hierarchy = ayon_api.get_folders_hierarchy(project_name)
|
||||
|
||||
folder_items = {}
|
||||
hierachy_queue = collections.deque(hierarchy["hierarchy"])
|
||||
while hierachy_queue:
|
||||
item = hierachy_queue.popleft()
|
||||
folder_item = _get_folder_item_from_hierarchy_item(item)
|
||||
folder_items[folder_item.entity_id] = folder_item
|
||||
hierachy_queue.extend(item["children"] or [])
|
||||
return folder_items
|
||||
|
||||
def _refresh_tasks_cache(self, project_name, folder_id, sender=None):
|
||||
if folder_id in self._tasks_refreshing:
|
||||
return
|
||||
|
||||
with self._task_refresh_event_manager(
|
||||
project_name, folder_id, sender
|
||||
):
|
||||
task_items = self._query_tasks(project_name, folder_id)
|
||||
self._task_items[project_name][folder_id] = task_items
|
||||
|
||||
def _query_tasks(self, project_name, folder_id):
|
||||
tasks = list(ayon_api.get_tasks(
|
||||
project_name,
|
||||
folder_ids=[folder_id],
|
||||
fields={"id", "name", "label", "folderId", "type"}
|
||||
))
|
||||
return _get_task_items_from_tasks(tasks)
|
||||
145
openpype/tools/ayon_utils/models/projects.py
Normal file
145
openpype/tools/ayon_utils/models/projects.py
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import contextlib
|
||||
from abc import ABCMeta, abstractmethod
|
||||
|
||||
import ayon_api
|
||||
import six
|
||||
|
||||
from openpype.style import get_default_entity_icon_color
|
||||
|
||||
from .cache import CacheItem
|
||||
|
||||
PROJECTS_MODEL_SENDER = "projects.model"
|
||||
|
||||
|
||||
@six.add_metaclass(ABCMeta)
|
||||
class AbstractHierarchyController:
|
||||
@abstractmethod
|
||||
def emit_event(self, topic, data, source):
|
||||
pass
|
||||
|
||||
|
||||
class ProjectItem:
|
||||
"""Item representing folder entity on a server.
|
||||
|
||||
Folder can be a child of another folder or a project.
|
||||
|
||||
Args:
|
||||
name (str): Project name.
|
||||
active (Union[str, None]): Parent folder id. If 'None' then project
|
||||
is parent.
|
||||
"""
|
||||
|
||||
def __init__(self, name, active, icon=None):
|
||||
self.name = name
|
||||
self.active = active
|
||||
if icon is None:
|
||||
icon = {
|
||||
"type": "awesome-font",
|
||||
"name": "fa.map",
|
||||
"color": get_default_entity_icon_color(),
|
||||
}
|
||||
self.icon = icon
|
||||
|
||||
def to_data(self):
|
||||
"""Converts folder item to data.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Folder item data.
|
||||
"""
|
||||
|
||||
return {
|
||||
"name": self.name,
|
||||
"active": self.active,
|
||||
"icon": self.icon,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data):
|
||||
"""Re-creates folder item from data.
|
||||
|
||||
Args:
|
||||
data (dict[str, Any]): Folder item data.
|
||||
|
||||
Returns:
|
||||
FolderItem: Folder item.
|
||||
"""
|
||||
|
||||
return cls(**data)
|
||||
|
||||
|
||||
def _get_project_items_from_entitiy(projects):
|
||||
"""
|
||||
|
||||
Args:
|
||||
projects (list[dict[str, Any]]): List of projects.
|
||||
|
||||
Returns:
|
||||
ProjectItem: Project item.
|
||||
"""
|
||||
|
||||
return [
|
||||
ProjectItem(project["name"], project["active"])
|
||||
for project in projects
|
||||
]
|
||||
|
||||
|
||||
class ProjectsModel(object):
|
||||
def __init__(self, controller):
|
||||
self._projects_cache = CacheItem(default_factory=dict)
|
||||
self._project_items_by_name = {}
|
||||
self._projects_by_name = {}
|
||||
|
||||
self._is_refreshing = False
|
||||
self._controller = controller
|
||||
|
||||
def reset(self):
|
||||
self._projects_cache.reset()
|
||||
self._project_items_by_name = {}
|
||||
self._projects_by_name = {}
|
||||
|
||||
def refresh(self):
|
||||
self._refresh_projects_cache()
|
||||
|
||||
def get_project_items(self, sender):
|
||||
if not self._projects_cache.is_valid:
|
||||
self._refresh_projects_cache(sender)
|
||||
return self._projects_cache.get_data()
|
||||
|
||||
def get_project_entity(self, project_name):
|
||||
if project_name not in self._projects_by_name:
|
||||
entity = None
|
||||
if project_name:
|
||||
entity = ayon_api.get_project(project_name)
|
||||
self._projects_by_name[project_name] = entity
|
||||
return self._projects_by_name[project_name]
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _project_refresh_event_manager(self, sender):
|
||||
self._is_refreshing = True
|
||||
self._controller.emit_event(
|
||||
"projects.refresh.started",
|
||||
{"sender": sender},
|
||||
PROJECTS_MODEL_SENDER
|
||||
)
|
||||
try:
|
||||
yield
|
||||
|
||||
finally:
|
||||
self._controller.emit_event(
|
||||
"projects.refresh.finished",
|
||||
{"sender": sender},
|
||||
PROJECTS_MODEL_SENDER
|
||||
)
|
||||
self._is_refreshing = False
|
||||
|
||||
def _refresh_projects_cache(self, sender=None):
|
||||
if self._is_refreshing:
|
||||
return
|
||||
|
||||
with self._project_refresh_event_manager(sender):
|
||||
project_items = self._query_projects()
|
||||
self._projects_cache.update_data(project_items)
|
||||
|
||||
def _query_projects(self):
|
||||
projects = ayon_api.get_projects(fields=["name", "active"])
|
||||
return _get_project_items_from_entitiy(projects)
|
||||
37
openpype/tools/ayon_utils/widgets/__init__.py
Normal file
37
openpype/tools/ayon_utils/widgets/__init__.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
from .projects_widget import (
|
||||
# ProjectsWidget,
|
||||
ProjectsCombobox,
|
||||
ProjectsModel,
|
||||
ProjectSortFilterProxy,
|
||||
)
|
||||
|
||||
from .folders_widget import (
|
||||
FoldersWidget,
|
||||
FoldersModel,
|
||||
)
|
||||
|
||||
from .tasks_widget import (
|
||||
TasksWidget,
|
||||
TasksModel,
|
||||
)
|
||||
from .utils import (
|
||||
get_qt_icon,
|
||||
RefreshThread,
|
||||
)
|
||||
|
||||
|
||||
__all__ = (
|
||||
# "ProjectsWidget",
|
||||
"ProjectsCombobox",
|
||||
"ProjectsModel",
|
||||
"ProjectSortFilterProxy",
|
||||
|
||||
"FoldersWidget",
|
||||
"FoldersModel",
|
||||
|
||||
"TasksWidget",
|
||||
"TasksModel",
|
||||
|
||||
"get_qt_icon",
|
||||
"RefreshThread",
|
||||
)
|
||||
364
openpype/tools/ayon_utils/widgets/folders_widget.py
Normal file
364
openpype/tools/ayon_utils/widgets/folders_widget.py
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
import collections
|
||||
|
||||
from qtpy import QtWidgets, QtGui, QtCore
|
||||
|
||||
from openpype.tools.utils import (
|
||||
RecursiveSortFilterProxyModel,
|
||||
DeselectableTreeView,
|
||||
)
|
||||
|
||||
from .utils import RefreshThread, get_qt_icon
|
||||
|
||||
SENDER_NAME = "qt_folders_model"
|
||||
ITEM_ID_ROLE = QtCore.Qt.UserRole + 1
|
||||
ITEM_NAME_ROLE = QtCore.Qt.UserRole + 2
|
||||
|
||||
|
||||
class FoldersModel(QtGui.QStandardItemModel):
|
||||
"""Folders model which cares about refresh of folders.
|
||||
|
||||
Args:
|
||||
controller (AbstractWorkfilesFrontend): The control object.
|
||||
"""
|
||||
|
||||
refreshed = QtCore.Signal()
|
||||
|
||||
def __init__(self, controller):
|
||||
super(FoldersModel, self).__init__()
|
||||
|
||||
self._controller = controller
|
||||
self._items_by_id = {}
|
||||
self._parent_id_by_id = {}
|
||||
|
||||
self._refresh_threads = {}
|
||||
self._current_refresh_thread = None
|
||||
self._last_project_name = None
|
||||
|
||||
self._has_content = False
|
||||
self._is_refreshing = False
|
||||
|
||||
@property
|
||||
def is_refreshing(self):
|
||||
"""Model is refreshing.
|
||||
|
||||
Returns:
|
||||
bool: True if model is refreshing.
|
||||
"""
|
||||
return self._is_refreshing
|
||||
|
||||
@property
|
||||
def has_content(self):
|
||||
"""Has at least one folder.
|
||||
|
||||
Returns:
|
||||
bool: True if model has at least one folder.
|
||||
"""
|
||||
|
||||
return self._has_content
|
||||
|
||||
def clear(self):
|
||||
self._items_by_id = {}
|
||||
self._parent_id_by_id = {}
|
||||
self._has_content = False
|
||||
super(FoldersModel, self).clear()
|
||||
|
||||
def get_index_by_id(self, item_id):
|
||||
"""Get index by folder id.
|
||||
|
||||
Returns:
|
||||
QtCore.QModelIndex: Index of the folder. Can be invalid if folder
|
||||
is not available.
|
||||
"""
|
||||
item = self._items_by_id.get(item_id)
|
||||
if item is None:
|
||||
return QtCore.QModelIndex()
|
||||
return self.indexFromItem(item)
|
||||
|
||||
def set_project_name(self, project_name):
|
||||
"""Refresh folders items.
|
||||
|
||||
Refresh start thread because it can cause that controller can
|
||||
start query from database if folders are not cached.
|
||||
"""
|
||||
|
||||
if not project_name:
|
||||
self._last_project_name = project_name
|
||||
self._current_refresh_thread = None
|
||||
self._fill_items({})
|
||||
return
|
||||
|
||||
self._is_refreshing = True
|
||||
|
||||
if self._last_project_name != project_name:
|
||||
self.clear()
|
||||
self._last_project_name = project_name
|
||||
|
||||
thread = self._refresh_threads.get(project_name)
|
||||
if thread is not None:
|
||||
self._current_refresh_thread = thread
|
||||
return
|
||||
|
||||
thread = RefreshThread(
|
||||
project_name,
|
||||
self._controller.get_folder_items,
|
||||
project_name,
|
||||
SENDER_NAME
|
||||
)
|
||||
self._current_refresh_thread = thread
|
||||
self._refresh_threads[thread.id] = thread
|
||||
thread.refresh_finished.connect(self._on_refresh_thread)
|
||||
thread.start()
|
||||
|
||||
def _on_refresh_thread(self, thread_id):
|
||||
"""Callback when refresh thread is finished.
|
||||
|
||||
Technically can be running multiple refresh threads at the same time,
|
||||
to avoid using values from wrong thread, we check if thread id is
|
||||
current refresh thread id.
|
||||
|
||||
Folders are stored by id.
|
||||
|
||||
Args:
|
||||
thread_id (str): Thread id.
|
||||
"""
|
||||
|
||||
# Make sure to remove thread from '_refresh_threads' dict
|
||||
thread = self._refresh_threads.pop(thread_id)
|
||||
if (
|
||||
self._current_refresh_thread is None
|
||||
or thread_id != self._current_refresh_thread.id
|
||||
):
|
||||
return
|
||||
|
||||
self._fill_items(thread.get_result())
|
||||
|
||||
def _fill_items(self, folder_items_by_id):
|
||||
if not folder_items_by_id:
|
||||
if folder_items_by_id is not None:
|
||||
self.clear()
|
||||
self._is_refreshing = False
|
||||
self.refreshed.emit()
|
||||
return
|
||||
|
||||
self._has_content = True
|
||||
|
||||
folder_ids = set(folder_items_by_id)
|
||||
ids_to_remove = set(self._items_by_id) - folder_ids
|
||||
|
||||
folder_items_by_parent = collections.defaultdict(dict)
|
||||
for folder_item in folder_items_by_id.values():
|
||||
(
|
||||
folder_items_by_parent
|
||||
[folder_item.parent_id]
|
||||
[folder_item.entity_id]
|
||||
) = folder_item
|
||||
|
||||
hierarchy_queue = collections.deque()
|
||||
hierarchy_queue.append((self.invisibleRootItem(), None))
|
||||
|
||||
# Keep pointers to removed items until the refresh finishes
|
||||
# - some children of the items could be moved and reused elsewhere
|
||||
removed_items = []
|
||||
while hierarchy_queue:
|
||||
item = hierarchy_queue.popleft()
|
||||
parent_item, parent_id = item
|
||||
folder_items = folder_items_by_parent[parent_id]
|
||||
|
||||
items_by_id = {}
|
||||
folder_ids_to_add = set(folder_items)
|
||||
for row_idx in reversed(range(parent_item.rowCount())):
|
||||
child_item = parent_item.child(row_idx)
|
||||
child_id = child_item.data(ITEM_ID_ROLE)
|
||||
if child_id in ids_to_remove:
|
||||
removed_items.append(parent_item.takeRow(row_idx))
|
||||
else:
|
||||
items_by_id[child_id] = child_item
|
||||
|
||||
new_items = []
|
||||
for item_id in folder_ids_to_add:
|
||||
folder_item = folder_items[item_id]
|
||||
item = items_by_id.get(item_id)
|
||||
if item is None:
|
||||
is_new = True
|
||||
item = QtGui.QStandardItem()
|
||||
item.setEditable(False)
|
||||
else:
|
||||
is_new = self._parent_id_by_id[item_id] != parent_id
|
||||
|
||||
icon = get_qt_icon(folder_item.icon)
|
||||
item.setData(item_id, ITEM_ID_ROLE)
|
||||
item.setData(folder_item.name, ITEM_NAME_ROLE)
|
||||
item.setData(folder_item.label, QtCore.Qt.DisplayRole)
|
||||
item.setData(icon, QtCore.Qt.DecorationRole)
|
||||
if is_new:
|
||||
new_items.append(item)
|
||||
self._items_by_id[item_id] = item
|
||||
self._parent_id_by_id[item_id] = parent_id
|
||||
|
||||
hierarchy_queue.append((item, item_id))
|
||||
|
||||
if new_items:
|
||||
parent_item.appendRows(new_items)
|
||||
|
||||
for item_id in ids_to_remove:
|
||||
self._items_by_id.pop(item_id)
|
||||
self._parent_id_by_id.pop(item_id)
|
||||
|
||||
self._is_refreshing = False
|
||||
self.refreshed.emit()
|
||||
|
||||
|
||||
class FoldersWidget(QtWidgets.QWidget):
|
||||
"""Folders widget.
|
||||
|
||||
Widget that handles folders view, model and selection.
|
||||
|
||||
Expected selection handling is disabled by default. If enabled, the
|
||||
widget will handle the expected in predefined way. Widget is listening
|
||||
to event 'expected_selection_changed' with expected event data below,
|
||||
the same data must be available when called method
|
||||
'get_expected_selection_data' on controller.
|
||||
|
||||
{
|
||||
"folder": {
|
||||
"current": bool, # Folder is what should be set now
|
||||
"folder_id": Union[str, None], # Folder id that should be selected
|
||||
},
|
||||
...
|
||||
}
|
||||
|
||||
Selection is confirmed by calling method 'expected_folder_selected' on
|
||||
controller.
|
||||
|
||||
|
||||
Args:
|
||||
controller (AbstractWorkfilesFrontend): The control object.
|
||||
parent (QtWidgets.QWidget): The parent widget.
|
||||
handle_expected_selection (bool): If True, the widget will handle
|
||||
the expected selection. Defaults to False.
|
||||
"""
|
||||
|
||||
def __init__(self, controller, parent, handle_expected_selection=False):
|
||||
super(FoldersWidget, self).__init__(parent)
|
||||
|
||||
folders_view = DeselectableTreeView(self)
|
||||
folders_view.setHeaderHidden(True)
|
||||
|
||||
folders_model = FoldersModel(controller)
|
||||
folders_proxy_model = RecursiveSortFilterProxyModel()
|
||||
folders_proxy_model.setSourceModel(folders_model)
|
||||
|
||||
folders_view.setModel(folders_proxy_model)
|
||||
|
||||
main_layout = QtWidgets.QHBoxLayout(self)
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
main_layout.addWidget(folders_view, 1)
|
||||
|
||||
controller.register_event_callback(
|
||||
"selection.project.changed",
|
||||
self._on_project_selection_change,
|
||||
)
|
||||
controller.register_event_callback(
|
||||
"folders.refresh.finished",
|
||||
self._on_folders_refresh_finished
|
||||
)
|
||||
controller.register_event_callback(
|
||||
"controller.refresh.finished",
|
||||
self._on_controller_refresh
|
||||
)
|
||||
controller.register_event_callback(
|
||||
"expected_selection_changed",
|
||||
self._on_expected_selection_change
|
||||
)
|
||||
|
||||
selection_model = folders_view.selectionModel()
|
||||
selection_model.selectionChanged.connect(self._on_selection_change)
|
||||
|
||||
folders_model.refreshed.connect(self._on_model_refresh)
|
||||
|
||||
self._controller = controller
|
||||
self._folders_view = folders_view
|
||||
self._folders_model = folders_model
|
||||
self._folders_proxy_model = folders_proxy_model
|
||||
|
||||
self._handle_expected_selection = handle_expected_selection
|
||||
self._expected_selection = None
|
||||
|
||||
def set_name_filer(self, name):
|
||||
"""Set filter of folder name.
|
||||
|
||||
Args:
|
||||
name (str): The string filter.
|
||||
"""
|
||||
|
||||
self._folders_proxy_model.setFilterFixedString(name)
|
||||
|
||||
def _on_project_selection_change(self, event):
|
||||
project_name = event["project_name"]
|
||||
self._set_project_name(project_name)
|
||||
|
||||
def _set_project_name(self, project_name):
|
||||
self._folders_model.set_project_name(project_name)
|
||||
|
||||
def _clear(self):
|
||||
self._folders_model.clear()
|
||||
|
||||
def _on_folders_refresh_finished(self, event):
|
||||
if event["sender"] != SENDER_NAME:
|
||||
self._set_project_name(event["project_name"])
|
||||
|
||||
def _on_controller_refresh(self):
|
||||
self._update_expected_selection()
|
||||
|
||||
def _on_model_refresh(self):
|
||||
if self._expected_selection:
|
||||
self._set_expected_selection()
|
||||
self._folders_proxy_model.sort(0)
|
||||
|
||||
def _get_selected_item_id(self):
|
||||
selection_model = self._folders_view.selectionModel()
|
||||
for index in selection_model.selectedIndexes():
|
||||
item_id = index.data(ITEM_ID_ROLE)
|
||||
if item_id is not None:
|
||||
return item_id
|
||||
return None
|
||||
|
||||
def _on_selection_change(self):
|
||||
item_id = self._get_selected_item_id()
|
||||
self._controller.set_selected_folder(item_id)
|
||||
|
||||
# Expected selection handling
|
||||
def _on_expected_selection_change(self, event):
|
||||
self._update_expected_selection(event.data)
|
||||
|
||||
def _update_expected_selection(self, expected_data=None):
|
||||
if not self._handle_expected_selection:
|
||||
return
|
||||
|
||||
if expected_data is None:
|
||||
expected_data = self._controller.get_expected_selection_data()
|
||||
|
||||
folder_data = expected_data.get("folder")
|
||||
if not folder_data or not folder_data["current"]:
|
||||
return
|
||||
|
||||
folder_id = folder_data["id"]
|
||||
self._expected_selection = folder_id
|
||||
if not self._folders_model.is_refreshing:
|
||||
self._set_expected_selection()
|
||||
|
||||
def _set_expected_selection(self):
|
||||
if not self._handle_expected_selection:
|
||||
return
|
||||
|
||||
folder_id = self._expected_selection
|
||||
self._expected_selection = None
|
||||
if (
|
||||
folder_id is not None
|
||||
and folder_id != self._get_selected_item_id()
|
||||
):
|
||||
index = self._folders_model.get_index_by_id(folder_id)
|
||||
if index.isValid():
|
||||
proxy_index = self._folders_proxy_model.mapFromSource(index)
|
||||
self._folders_view.setCurrentIndex(proxy_index)
|
||||
self._controller.expected_folder_selected(folder_id)
|
||||
325
openpype/tools/ayon_utils/widgets/projects_widget.py
Normal file
325
openpype/tools/ayon_utils/widgets/projects_widget.py
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
from qtpy import QtWidgets, QtCore, QtGui
|
||||
|
||||
from openpype.tools.ayon_utils.models import PROJECTS_MODEL_SENDER
|
||||
from .utils import RefreshThread, get_qt_icon
|
||||
|
||||
PROJECT_NAME_ROLE = QtCore.Qt.UserRole + 1
|
||||
PROJECT_IS_ACTIVE_ROLE = QtCore.Qt.UserRole + 2
|
||||
|
||||
|
||||
class ProjectsModel(QtGui.QStandardItemModel):
|
||||
refreshed = QtCore.Signal()
|
||||
|
||||
def __init__(self, controller):
|
||||
super(ProjectsModel, self).__init__()
|
||||
self._controller = controller
|
||||
|
||||
self._project_items = {}
|
||||
|
||||
self._empty_item = None
|
||||
self._empty_item_added = False
|
||||
|
||||
self._is_refreshing = False
|
||||
self._refresh_thread = None
|
||||
|
||||
@property
|
||||
def is_refreshing(self):
|
||||
return self._is_refreshing
|
||||
|
||||
def refresh(self):
|
||||
self._refresh()
|
||||
|
||||
def has_content(self):
|
||||
return len(self._project_items) > 0
|
||||
|
||||
def _add_empty_item(self):
|
||||
item = self._get_empty_item()
|
||||
if not self._empty_item_added:
|
||||
root_item = self.invisibleRootItem()
|
||||
root_item.appendRow(item)
|
||||
self._empty_item_added = True
|
||||
|
||||
def _remove_empty_item(self):
|
||||
if not self._empty_item_added:
|
||||
return
|
||||
|
||||
root_item = self.invisibleRootItem()
|
||||
item = self._get_empty_item()
|
||||
root_item.takeRow(item.row())
|
||||
self._empty_item_added = False
|
||||
|
||||
def _get_empty_item(self):
|
||||
if self._empty_item is None:
|
||||
item = QtGui.QStandardItem("< No projects >")
|
||||
item.setFlags(QtCore.Qt.NoItemFlags)
|
||||
self._empty_item = item
|
||||
return self._empty_item
|
||||
|
||||
def _refresh(self):
|
||||
if self._is_refreshing:
|
||||
return
|
||||
self._is_refreshing = True
|
||||
refresh_thread = RefreshThread(
|
||||
"projects", self._query_project_items
|
||||
)
|
||||
refresh_thread.refresh_finished.connect(self._refresh_finished)
|
||||
refresh_thread.start()
|
||||
self._refresh_thread = refresh_thread
|
||||
|
||||
def _query_project_items(self):
|
||||
return self._controller.get_project_items()
|
||||
|
||||
def _refresh_finished(self):
|
||||
# TODO check if failed
|
||||
result = self._refresh_thread.get_result()
|
||||
self._refresh_thread = None
|
||||
|
||||
self._fill_items(result)
|
||||
|
||||
self._is_refreshing = False
|
||||
self.refreshed.emit()
|
||||
|
||||
def _fill_items(self, project_items):
|
||||
items_to_remove = set(self._project_items.keys())
|
||||
new_items = []
|
||||
for project_item in project_items:
|
||||
project_name = project_item.name
|
||||
items_to_remove.discard(project_name)
|
||||
item = self._project_items.get(project_name)
|
||||
if item is None:
|
||||
item = QtGui.QStandardItem()
|
||||
new_items.append(item)
|
||||
icon = get_qt_icon(project_item.icon)
|
||||
item.setData(project_name, QtCore.Qt.DisplayRole)
|
||||
item.setData(icon, QtCore.Qt.DecorationRole)
|
||||
item.setData(project_name, PROJECT_NAME_ROLE)
|
||||
item.setData(project_item.active, PROJECT_IS_ACTIVE_ROLE)
|
||||
self._project_items[project_name] = item
|
||||
|
||||
root_item = self.invisibleRootItem()
|
||||
if new_items:
|
||||
root_item.appendRows(new_items)
|
||||
|
||||
for project_name in items_to_remove:
|
||||
item = self._project_items.pop(project_name)
|
||||
root_item.removeRow(item.row())
|
||||
|
||||
if self.has_content():
|
||||
self._remove_empty_item()
|
||||
else:
|
||||
self._add_empty_item()
|
||||
|
||||
|
||||
class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(ProjectSortFilterProxy, self).__init__(*args, **kwargs)
|
||||
self._filter_inactive = True
|
||||
# Disable case sensitivity
|
||||
self.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive)
|
||||
|
||||
def lessThan(self, left_index, right_index):
|
||||
if left_index.data(PROJECT_NAME_ROLE) is None:
|
||||
return True
|
||||
|
||||
if right_index.data(PROJECT_NAME_ROLE) is None:
|
||||
return False
|
||||
|
||||
left_is_active = left_index.data(PROJECT_IS_ACTIVE_ROLE)
|
||||
right_is_active = right_index.data(PROJECT_IS_ACTIVE_ROLE)
|
||||
if right_is_active == left_is_active:
|
||||
return super(ProjectSortFilterProxy, self).lessThan(
|
||||
left_index, right_index
|
||||
)
|
||||
|
||||
if left_is_active:
|
||||
return True
|
||||
return False
|
||||
|
||||
def filterAcceptsRow(self, source_row, source_parent):
|
||||
index = self.sourceModel().index(source_row, 0, source_parent)
|
||||
string_pattern = self.filterRegularExpression().pattern()
|
||||
if (
|
||||
self._filter_inactive
|
||||
and not index.data(PROJECT_IS_ACTIVE_ROLE)
|
||||
):
|
||||
return False
|
||||
|
||||
if string_pattern:
|
||||
project_name = index.data(PROJECT_IS_ACTIVE_ROLE)
|
||||
if project_name is not None:
|
||||
return string_pattern.lower() in project_name.lower()
|
||||
|
||||
return super(ProjectSortFilterProxy, self).filterAcceptsRow(
|
||||
source_row, source_parent
|
||||
)
|
||||
|
||||
def _custom_index_filter(self, index):
|
||||
return bool(index.data(PROJECT_IS_ACTIVE_ROLE))
|
||||
|
||||
def is_active_filter_enabled(self):
|
||||
return self._filter_inactive
|
||||
|
||||
def set_active_filter_enabled(self, value):
|
||||
if self._filter_inactive == value:
|
||||
return
|
||||
self._filter_inactive = value
|
||||
self.invalidateFilter()
|
||||
|
||||
|
||||
class ProjectsCombobox(QtWidgets.QWidget):
|
||||
def __init__(self, controller, parent, handle_expected_selection=False):
|
||||
super(ProjectsCombobox, self).__init__(parent)
|
||||
|
||||
projects_combobox = QtWidgets.QComboBox(self)
|
||||
combobox_delegate = QtWidgets.QStyledItemDelegate(projects_combobox)
|
||||
projects_combobox.setItemDelegate(combobox_delegate)
|
||||
projects_model = ProjectsModel(controller)
|
||||
projects_proxy_model = ProjectSortFilterProxy()
|
||||
projects_proxy_model.setSourceModel(projects_model)
|
||||
projects_combobox.setModel(projects_proxy_model)
|
||||
|
||||
main_layout = QtWidgets.QHBoxLayout(self)
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
main_layout.addWidget(projects_combobox, 1)
|
||||
|
||||
projects_model.refreshed.connect(self._on_model_refresh)
|
||||
|
||||
controller.register_event_callback(
|
||||
"projects.refresh.finished",
|
||||
self._on_projects_refresh_finished
|
||||
)
|
||||
controller.register_event_callback(
|
||||
"controller.refresh.finished",
|
||||
self._on_controller_refresh
|
||||
)
|
||||
controller.register_event_callback(
|
||||
"expected_selection_changed",
|
||||
self._on_expected_selection_change
|
||||
)
|
||||
|
||||
projects_combobox.currentIndexChanged.connect(
|
||||
self._on_current_index_changed
|
||||
)
|
||||
|
||||
self._controller = controller
|
||||
self._listen_selection_change = True
|
||||
|
||||
self._handle_expected_selection = handle_expected_selection
|
||||
self._expected_selection = None
|
||||
|
||||
self._projects_combobox = projects_combobox
|
||||
self._projects_model = projects_model
|
||||
self._projects_proxy_model = projects_proxy_model
|
||||
self._combobox_delegate = combobox_delegate
|
||||
|
||||
def refresh(self):
|
||||
self._projects_model.refresh()
|
||||
|
||||
def set_selection(self, project_name):
|
||||
"""Set selection to a given project.
|
||||
|
||||
Selection change is ignored if project is not found.
|
||||
|
||||
Args:
|
||||
project_name (str): Name of project.
|
||||
|
||||
Returns:
|
||||
bool: True if selection was changed, False otherwise. NOTE:
|
||||
Selection may not be changed if project is not found, or if
|
||||
project is already selected.
|
||||
"""
|
||||
|
||||
idx = self._projects_combobox.findData(
|
||||
project_name, PROJECT_NAME_ROLE)
|
||||
if idx < 0:
|
||||
return False
|
||||
if idx != self._projects_combobox.currentIndex():
|
||||
self._projects_combobox.setCurrentIndex(idx)
|
||||
return True
|
||||
return False
|
||||
|
||||
def set_listen_to_selection_change(self, listen):
|
||||
"""Disable listening to changes of the selection.
|
||||
|
||||
Because combobox is triggering selection change when it's model
|
||||
is refreshed, it's necessary to disable listening to selection for
|
||||
some cases, e.g. when is on a different page of UI and should be just
|
||||
refreshed.
|
||||
|
||||
Args:
|
||||
listen (bool): Enable or disable listening to selection changes.
|
||||
"""
|
||||
|
||||
self._listen_selection_change = listen
|
||||
|
||||
def get_current_project_name(self):
|
||||
"""Name of selected project.
|
||||
|
||||
Returns:
|
||||
Union[str, None]: Name of selected project, or None if no project
|
||||
"""
|
||||
|
||||
idx = self._projects_combobox.currentIndex()
|
||||
if idx < 0:
|
||||
return None
|
||||
return self._projects_combobox.itemData(idx, PROJECT_NAME_ROLE)
|
||||
|
||||
def _on_current_index_changed(self, idx):
|
||||
if not self._listen_selection_change:
|
||||
return
|
||||
project_name = self._projects_combobox.itemData(
|
||||
idx, PROJECT_NAME_ROLE)
|
||||
self._controller.set_selected_project(project_name)
|
||||
|
||||
def _on_model_refresh(self):
|
||||
self._projects_proxy_model.sort(0)
|
||||
if self._expected_selection:
|
||||
self._set_expected_selection()
|
||||
|
||||
def _on_projects_refresh_finished(self, event):
|
||||
if event["sender"] != PROJECTS_MODEL_SENDER:
|
||||
self._projects_model.refresh()
|
||||
|
||||
def _on_controller_refresh(self):
|
||||
self._update_expected_selection()
|
||||
|
||||
# Expected selection handling
|
||||
def _on_expected_selection_change(self, event):
|
||||
self._update_expected_selection(event.data)
|
||||
|
||||
def _set_expected_selection(self):
|
||||
if not self._handle_expected_selection:
|
||||
return
|
||||
project_name = self._expected_selection
|
||||
if project_name is not None:
|
||||
if project_name != self.get_current_project_name():
|
||||
self.set_selection(project_name)
|
||||
else:
|
||||
# Fake project change
|
||||
self._on_current_index_changed(
|
||||
self._projects_combobox.currentIndex()
|
||||
)
|
||||
|
||||
self._controller.expected_project_selected(project_name)
|
||||
|
||||
def _update_expected_selection(self, expected_data=None):
|
||||
if not self._handle_expected_selection:
|
||||
return
|
||||
if expected_data is None:
|
||||
expected_data = self._controller.get_expected_selection_data()
|
||||
|
||||
project_data = expected_data.get("project")
|
||||
if (
|
||||
not project_data
|
||||
or not project_data["current"]
|
||||
or project_data["selected"]
|
||||
):
|
||||
return
|
||||
self._expected_selection = project_data["name"]
|
||||
if not self._projects_model.is_refreshing:
|
||||
self._set_expected_selection()
|
||||
|
||||
|
||||
class ProjectsWidget(QtWidgets.QWidget):
|
||||
# TODO implement
|
||||
pass
|
||||
436
openpype/tools/ayon_utils/widgets/tasks_widget.py
Normal file
436
openpype/tools/ayon_utils/widgets/tasks_widget.py
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
from qtpy import QtWidgets, QtGui, QtCore
|
||||
|
||||
from openpype.style import get_disabled_entity_icon_color
|
||||
from openpype.tools.utils import DeselectableTreeView
|
||||
|
||||
from .utils import RefreshThread, get_qt_icon
|
||||
|
||||
SENDER_NAME = "qt_tasks_model"
|
||||
ITEM_ID_ROLE = QtCore.Qt.UserRole + 1
|
||||
PARENT_ID_ROLE = QtCore.Qt.UserRole + 2
|
||||
ITEM_NAME_ROLE = QtCore.Qt.UserRole + 3
|
||||
TASK_TYPE_ROLE = QtCore.Qt.UserRole + 4
|
||||
|
||||
|
||||
class TasksModel(QtGui.QStandardItemModel):
|
||||
"""Tasks model which cares about refresh of tasks by folder id.
|
||||
|
||||
Args:
|
||||
controller (AbstractWorkfilesFrontend): The control object.
|
||||
"""
|
||||
|
||||
refreshed = QtCore.Signal()
|
||||
|
||||
def __init__(self, controller):
|
||||
super(TasksModel, self).__init__()
|
||||
|
||||
self._controller = controller
|
||||
|
||||
self._items_by_name = {}
|
||||
self._has_content = False
|
||||
self._is_refreshing = False
|
||||
|
||||
self._invalid_selection_item_used = False
|
||||
self._invalid_selection_item = None
|
||||
self._empty_tasks_item_used = False
|
||||
self._empty_tasks_item = None
|
||||
|
||||
self._last_project_name = None
|
||||
self._last_folder_id = None
|
||||
|
||||
self._refresh_threads = {}
|
||||
self._current_refresh_thread = None
|
||||
|
||||
# Initial state
|
||||
self._add_invalid_selection_item()
|
||||
|
||||
def clear(self):
|
||||
self._items_by_name = {}
|
||||
self._has_content = False
|
||||
self._remove_invalid_items()
|
||||
super(TasksModel, self).clear()
|
||||
|
||||
def refresh(self, project_name, folder_id):
|
||||
"""Refresh tasks for folder.
|
||||
|
||||
Args:
|
||||
project_name (Union[str]): Name of project.
|
||||
folder_id (Union[str, None]): Folder id.
|
||||
"""
|
||||
|
||||
self._refresh(project_name, folder_id)
|
||||
|
||||
def get_index_by_name(self, task_name):
|
||||
"""Find item by name and return its index.
|
||||
|
||||
Returns:
|
||||
QtCore.QModelIndex: Index of item. Is invalid if task is not
|
||||
found by name.
|
||||
"""
|
||||
|
||||
item = self._items_by_name.get(task_name)
|
||||
if item is None:
|
||||
return QtCore.QModelIndex()
|
||||
return self.indexFromItem(item)
|
||||
|
||||
def get_last_project_name(self):
|
||||
"""Get last refreshed project name.
|
||||
|
||||
Returns:
|
||||
Union[str, None]: Project name.
|
||||
"""
|
||||
|
||||
return self._last_project_name
|
||||
|
||||
def get_last_folder_id(self):
|
||||
"""Get last refreshed folder id.
|
||||
|
||||
Returns:
|
||||
Union[str, None]: Folder id.
|
||||
"""
|
||||
|
||||
return self._last_folder_id
|
||||
|
||||
def set_selected_project(self, project_name):
|
||||
self._selected_project_name = project_name
|
||||
|
||||
def _get_invalid_selection_item(self):
|
||||
if self._invalid_selection_item is None:
|
||||
item = QtGui.QStandardItem("Select a folder")
|
||||
item.setFlags(QtCore.Qt.NoItemFlags)
|
||||
icon = get_qt_icon({
|
||||
"type": "awesome-font",
|
||||
"name": "fa.times",
|
||||
"color": get_disabled_entity_icon_color(),
|
||||
})
|
||||
item.setData(icon, QtCore.Qt.DecorationRole)
|
||||
self._invalid_selection_item = item
|
||||
return self._invalid_selection_item
|
||||
|
||||
def _get_empty_task_item(self):
|
||||
if self._empty_tasks_item is None:
|
||||
item = QtGui.QStandardItem("No task")
|
||||
icon = get_qt_icon({
|
||||
"type": "awesome-font",
|
||||
"name": "fa.exclamation-circle",
|
||||
"color": get_disabled_entity_icon_color(),
|
||||
})
|
||||
item.setData(icon, QtCore.Qt.DecorationRole)
|
||||
item.setFlags(QtCore.Qt.NoItemFlags)
|
||||
self._empty_tasks_item = item
|
||||
return self._empty_tasks_item
|
||||
|
||||
def _add_invalid_item(self, item):
|
||||
self.clear()
|
||||
root_item = self.invisibleRootItem()
|
||||
root_item.appendRow(item)
|
||||
|
||||
def _remove_invalid_item(self, item):
|
||||
root_item = self.invisibleRootItem()
|
||||
root_item.takeRow(item.row())
|
||||
|
||||
def _remove_invalid_items(self):
|
||||
self._remove_invalid_selection_item()
|
||||
self._remove_empty_task_item()
|
||||
|
||||
def _add_invalid_selection_item(self):
|
||||
if not self._invalid_selection_item_used:
|
||||
self._add_invalid_item(self._get_invalid_selection_item())
|
||||
self._invalid_selection_item_used = True
|
||||
|
||||
def _remove_invalid_selection_item(self):
|
||||
if self._invalid_selection_item:
|
||||
self._remove_invalid_item(self._get_invalid_selection_item())
|
||||
self._invalid_selection_item_used = False
|
||||
|
||||
def _add_empty_task_item(self):
|
||||
if not self._empty_tasks_item_used:
|
||||
self._add_invalid_item(self._get_empty_task_item())
|
||||
self._empty_tasks_item_used = True
|
||||
|
||||
def _remove_empty_task_item(self):
|
||||
if self._empty_tasks_item_used:
|
||||
self._remove_invalid_item(self._get_empty_task_item())
|
||||
self._empty_tasks_item_used = False
|
||||
|
||||
def _refresh(self, project_name, folder_id):
|
||||
self._is_refreshing = True
|
||||
self._last_project_name = project_name
|
||||
self._last_folder_id = folder_id
|
||||
if not folder_id:
|
||||
self._add_invalid_selection_item()
|
||||
self._current_refresh_thread = None
|
||||
self._is_refreshing = False
|
||||
self.refreshed.emit()
|
||||
return
|
||||
|
||||
thread = self._refresh_threads.get(folder_id)
|
||||
if thread is not None:
|
||||
self._current_refresh_thread = thread
|
||||
return
|
||||
thread = RefreshThread(
|
||||
folder_id,
|
||||
self._controller.get_task_items,
|
||||
project_name,
|
||||
folder_id
|
||||
)
|
||||
self._current_refresh_thread = thread
|
||||
self._refresh_threads[thread.id] = thread
|
||||
thread.refresh_finished.connect(self._on_refresh_thread)
|
||||
thread.start()
|
||||
|
||||
def _on_refresh_thread(self, thread_id):
|
||||
"""Callback when refresh thread is finished.
|
||||
|
||||
Technically can be running multiple refresh threads at the same time,
|
||||
to avoid using values from wrong thread, we check if thread id is
|
||||
current refresh thread id.
|
||||
|
||||
Tasks are stored by name, so if a folder has same task name as
|
||||
previously selected folder it keeps the selection.
|
||||
|
||||
Args:
|
||||
thread_id (str): Thread id.
|
||||
"""
|
||||
|
||||
# Make sure to remove thread from '_refresh_threads' dict
|
||||
thread = self._refresh_threads.pop(thread_id)
|
||||
if (
|
||||
self._current_refresh_thread is None
|
||||
or thread_id != self._current_refresh_thread.id
|
||||
):
|
||||
return
|
||||
|
||||
task_items = thread.get_result()
|
||||
# Task items are refreshed
|
||||
if task_items is None:
|
||||
return
|
||||
|
||||
# No tasks are available on folder
|
||||
if not task_items:
|
||||
self._add_empty_task_item()
|
||||
return
|
||||
self._remove_invalid_items()
|
||||
|
||||
new_items = []
|
||||
new_names = set()
|
||||
for task_item in task_items:
|
||||
name = task_item.name
|
||||
new_names.add(name)
|
||||
item = self._items_by_name.get(name)
|
||||
if item is None:
|
||||
item = QtGui.QStandardItem()
|
||||
item.setEditable(False)
|
||||
new_items.append(item)
|
||||
self._items_by_name[name] = item
|
||||
|
||||
# TODO cache locally
|
||||
icon = get_qt_icon(task_item.icon)
|
||||
item.setData(task_item.label, QtCore.Qt.DisplayRole)
|
||||
item.setData(name, ITEM_NAME_ROLE)
|
||||
item.setData(task_item.id, ITEM_ID_ROLE)
|
||||
item.setData(task_item.parent_id, PARENT_ID_ROLE)
|
||||
item.setData(icon, QtCore.Qt.DecorationRole)
|
||||
|
||||
root_item = self.invisibleRootItem()
|
||||
|
||||
for name in set(self._items_by_name) - new_names:
|
||||
item = self._items_by_name.pop(name)
|
||||
root_item.removeRow(item.row())
|
||||
|
||||
if new_items:
|
||||
root_item.appendRows(new_items)
|
||||
|
||||
self._has_content = root_item.rowCount() > 0
|
||||
self._is_refreshing = False
|
||||
self.refreshed.emit()
|
||||
|
||||
@property
|
||||
def is_refreshing(self):
|
||||
"""Model is refreshing.
|
||||
|
||||
Returns:
|
||||
bool: Model is refreshing
|
||||
"""
|
||||
|
||||
return self._is_refreshing
|
||||
|
||||
@property
|
||||
def has_content(self):
|
||||
"""Model has content.
|
||||
|
||||
Returns:
|
||||
bools: Have at least one task.
|
||||
"""
|
||||
|
||||
return self._has_content
|
||||
|
||||
def headerData(self, section, orientation, role):
|
||||
# Show nice labels in the header
|
||||
if (
|
||||
role == QtCore.Qt.DisplayRole
|
||||
and orientation == QtCore.Qt.Horizontal
|
||||
):
|
||||
if section == 0:
|
||||
return "Tasks"
|
||||
|
||||
return super(TasksModel, self).headerData(
|
||||
section, orientation, role
|
||||
)
|
||||
|
||||
|
||||
class TasksWidget(QtWidgets.QWidget):
|
||||
"""Tasks widget.
|
||||
|
||||
Widget that handles tasks view, model and selection.
|
||||
|
||||
Args:
|
||||
controller (AbstractWorkfilesFrontend): Workfiles controller.
|
||||
parent (QtWidgets.QWidget): Parent widget.
|
||||
handle_expected_selection (Optional[bool]): Handle expected selection.
|
||||
"""
|
||||
|
||||
def __init__(self, controller, parent, handle_expected_selection=False):
|
||||
super(TasksWidget, self).__init__(parent)
|
||||
|
||||
tasks_view = DeselectableTreeView(self)
|
||||
tasks_view.setIndentation(0)
|
||||
|
||||
tasks_model = TasksModel(controller)
|
||||
tasks_proxy_model = QtCore.QSortFilterProxyModel()
|
||||
tasks_proxy_model.setSourceModel(tasks_model)
|
||||
|
||||
tasks_view.setModel(tasks_proxy_model)
|
||||
|
||||
main_layout = QtWidgets.QHBoxLayout(self)
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
main_layout.addWidget(tasks_view, 1)
|
||||
|
||||
controller.register_event_callback(
|
||||
"tasks.refresh.finished",
|
||||
self._on_tasks_refresh_finished
|
||||
)
|
||||
controller.register_event_callback(
|
||||
"selection.folder.changed",
|
||||
self._folder_selection_changed
|
||||
)
|
||||
controller.register_event_callback(
|
||||
"expected_selection_changed",
|
||||
self._on_expected_selection_change
|
||||
)
|
||||
|
||||
selection_model = tasks_view.selectionModel()
|
||||
selection_model.selectionChanged.connect(self._on_selection_change)
|
||||
|
||||
tasks_model.refreshed.connect(self._on_tasks_model_refresh)
|
||||
|
||||
self._controller = controller
|
||||
self._tasks_view = tasks_view
|
||||
self._tasks_model = tasks_model
|
||||
self._tasks_proxy_model = tasks_proxy_model
|
||||
|
||||
self._selected_folder_id = None
|
||||
|
||||
self._handle_expected_selection = handle_expected_selection
|
||||
self._expected_selection_data = None
|
||||
|
||||
def _clear(self):
|
||||
self._tasks_model.clear()
|
||||
|
||||
def _on_tasks_refresh_finished(self, event):
|
||||
"""Tasks were refreshed in controller.
|
||||
|
||||
Ignore if refresh was triggered by tasks model, or refreshed folder is
|
||||
not the same as currently selected folder.
|
||||
|
||||
Args:
|
||||
event (Event): Event object.
|
||||
"""
|
||||
|
||||
# Refresh only if current folder id is the same
|
||||
if (
|
||||
event["sender"] == SENDER_NAME
|
||||
or event["folder_id"] != self._selected_folder_id
|
||||
):
|
||||
return
|
||||
self._tasks_model.refresh(
|
||||
event["project_name"], self._selected_folder_id
|
||||
)
|
||||
|
||||
def _folder_selection_changed(self, event):
|
||||
self._selected_folder_id = event["folder_id"]
|
||||
self._tasks_model.refresh(
|
||||
event["project_name"], self._selected_folder_id
|
||||
)
|
||||
|
||||
def _on_tasks_model_refresh(self):
|
||||
if not self._set_expected_selection():
|
||||
self._on_selection_change()
|
||||
self._tasks_proxy_model.sort(0)
|
||||
|
||||
def _get_selected_item_ids(self):
|
||||
selection_model = self._tasks_view.selectionModel()
|
||||
for index in selection_model.selectedIndexes():
|
||||
task_id = index.data(ITEM_ID_ROLE)
|
||||
task_name = index.data(ITEM_NAME_ROLE)
|
||||
parent_id = index.data(PARENT_ID_ROLE)
|
||||
if task_name is not None:
|
||||
return parent_id, task_id, task_name
|
||||
return self._selected_folder_id, None, None
|
||||
|
||||
def _on_selection_change(self):
|
||||
# Don't trigger task change during refresh
|
||||
# - a task was deselected if that happens
|
||||
# - can cause crash triggered during tasks refreshing
|
||||
if self._tasks_model.is_refreshing:
|
||||
return
|
||||
|
||||
parent_id, task_id, task_name = self._get_selected_item_ids()
|
||||
self._controller.set_selected_task(task_id, task_name)
|
||||
|
||||
# Expected selection handling
|
||||
def _on_expected_selection_change(self, event):
|
||||
self._update_expected_selection(event.data)
|
||||
|
||||
def _set_expected_selection(self):
|
||||
if not self._handle_expected_selection:
|
||||
return False
|
||||
|
||||
if self._expected_selection_data is None:
|
||||
return False
|
||||
folder_id = self._expected_selection_data["folder_id"]
|
||||
task_name = self._expected_selection_data["task_name"]
|
||||
self._expected_selection_data = None
|
||||
model_folder_id = self._tasks_model.get_last_folder_id()
|
||||
if folder_id != model_folder_id:
|
||||
return False
|
||||
if task_name is not None:
|
||||
index = self._tasks_model.get_index_by_name(task_name)
|
||||
if index.isValid():
|
||||
proxy_index = self._tasks_proxy_model.mapFromSource(index)
|
||||
self._tasks_view.setCurrentIndex(proxy_index)
|
||||
self._controller.expected_task_selected(folder_id, task_name)
|
||||
return True
|
||||
|
||||
def _update_expected_selection(self, expected_data=None):
|
||||
if not self._handle_expected_selection:
|
||||
return
|
||||
if expected_data is None:
|
||||
expected_data = self._controller.get_expected_selection_data()
|
||||
folder_data = expected_data.get("folder")
|
||||
task_data = expected_data.get("task")
|
||||
if (
|
||||
not folder_data
|
||||
or not task_data
|
||||
or not task_data["current"]
|
||||
):
|
||||
return
|
||||
folder_id = folder_data["id"]
|
||||
self._expected_selection_data = {
|
||||
"task_name": task_data["name"],
|
||||
"folder_id": folder_id,
|
||||
}
|
||||
model_folder_id = self._tasks_model.get_last_folder_id()
|
||||
if folder_id != model_folder_id or self._tasks_model.is_refreshing:
|
||||
return
|
||||
self._set_expected_selection()
|
||||
98
openpype/tools/ayon_utils/widgets/utils.py
Normal file
98
openpype/tools/ayon_utils/widgets/utils.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import os
|
||||
from functools import partial
|
||||
|
||||
from qtpy import QtCore, QtGui
|
||||
|
||||
from openpype.tools.utils.lib import get_qta_icon_by_name_and_color
|
||||
|
||||
|
||||
class RefreshThread(QtCore.QThread):
|
||||
refresh_finished = QtCore.Signal(str)
|
||||
|
||||
def __init__(self, thread_id, func, *args, **kwargs):
|
||||
super(RefreshThread, self).__init__()
|
||||
self._id = thread_id
|
||||
self._callback = partial(func, *args, **kwargs)
|
||||
self._exception = None
|
||||
self._result = None
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
return self._id
|
||||
|
||||
@property
|
||||
def failed(self):
|
||||
return self._exception is not None
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
self._result = self._callback()
|
||||
except Exception as exc:
|
||||
self._exception = exc
|
||||
self.refresh_finished.emit(self.id)
|
||||
|
||||
def get_result(self):
|
||||
return self._result
|
||||
|
||||
|
||||
class _IconsCache:
|
||||
"""Cache for icons."""
|
||||
|
||||
_cache = {}
|
||||
_default = None
|
||||
|
||||
@classmethod
|
||||
def _get_cache_key(cls, icon_def):
|
||||
parts = []
|
||||
icon_type = icon_def["type"]
|
||||
if icon_type == "path":
|
||||
parts = [icon_type, icon_def["path"]]
|
||||
|
||||
elif icon_type == "awesome-font":
|
||||
parts = [icon_type, icon_def["name"], icon_def["color"]]
|
||||
return "|".join(parts)
|
||||
|
||||
@classmethod
|
||||
def get_icon(cls, icon_def):
|
||||
icon_type = icon_def["type"]
|
||||
cache_key = cls._get_cache_key(icon_def)
|
||||
cache = cls._cache.get(cache_key)
|
||||
if cache is not None:
|
||||
return cache
|
||||
|
||||
icon = None
|
||||
if icon_type == "path":
|
||||
path = icon_def["path"]
|
||||
if os.path.exists(path):
|
||||
icon = QtGui.QIcon(path)
|
||||
|
||||
elif icon_type == "awesome-font":
|
||||
icon_name = icon_def["name"]
|
||||
icon_color = icon_def["color"]
|
||||
icon = get_qta_icon_by_name_and_color(icon_name, icon_color)
|
||||
if icon is None:
|
||||
icon = get_qta_icon_by_name_and_color(
|
||||
"fa.{}".format(icon_name), icon_color)
|
||||
if icon is None:
|
||||
icon = cls.get_default()
|
||||
cls._cache[cache_key] = icon
|
||||
return icon
|
||||
|
||||
@classmethod
|
||||
def get_default(cls):
|
||||
pix = QtGui.QPixmap(1, 1)
|
||||
pix.fill(QtCore.Qt.transparent)
|
||||
return QtGui.QIcon(pix)
|
||||
|
||||
|
||||
def get_qt_icon(icon_def):
|
||||
"""Returns icon from cache or creates new one.
|
||||
|
||||
Args:
|
||||
icon_def (dict[str, Any]): Icon definition.
|
||||
|
||||
Returns:
|
||||
QtGui.QIcon: Icon.
|
||||
"""
|
||||
|
||||
return _IconsCache.get_icon(icon_def)
|
||||
|
|
@ -1,8 +1,5 @@
|
|||
import os
|
||||
|
||||
from qtpy import QtWidgets, QtGui
|
||||
|
||||
from openpype import PLUGINS_DIR
|
||||
from openpype import style
|
||||
from openpype import resources
|
||||
from openpype.lib import (
|
||||
|
|
@ -10,46 +7,7 @@ from openpype.lib import (
|
|||
ApplictionExecutableNotFound,
|
||||
ApplicationLaunchFailed
|
||||
)
|
||||
from openpype.pipeline import (
|
||||
LauncherAction,
|
||||
register_launcher_action_path,
|
||||
)
|
||||
|
||||
|
||||
def register_actions_from_paths(paths):
|
||||
if not paths:
|
||||
return
|
||||
|
||||
for path in paths:
|
||||
if not path:
|
||||
continue
|
||||
|
||||
if path.startswith("."):
|
||||
print((
|
||||
"BUG: Relative paths are not allowed for security reasons. {}"
|
||||
).format(path))
|
||||
continue
|
||||
|
||||
if not os.path.exists(path):
|
||||
print("Path was not found: {}".format(path))
|
||||
continue
|
||||
|
||||
register_launcher_action_path(path)
|
||||
|
||||
|
||||
def register_config_actions():
|
||||
"""Register actions from the configuration for Launcher"""
|
||||
|
||||
actions_dir = os.path.join(PLUGINS_DIR, "actions")
|
||||
if os.path.exists(actions_dir):
|
||||
register_actions_from_paths([actions_dir])
|
||||
|
||||
|
||||
def register_environment_actions():
|
||||
"""Register actions from AVALON_ACTIONS for Launcher."""
|
||||
|
||||
paths_str = os.environ.get("AVALON_ACTIONS") or ""
|
||||
register_actions_from_paths(paths_str.split(os.pathsep))
|
||||
from openpype.pipeline import LauncherAction
|
||||
|
||||
|
||||
# TODO move to 'openpype.pipeline.actions'
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ from .widgets import (
|
|||
IconButton,
|
||||
PixmapButton,
|
||||
SeparatorWidget,
|
||||
VerticalExpandButton,
|
||||
SquareButton,
|
||||
RefreshButton,
|
||||
GoToCurrentButton,
|
||||
)
|
||||
from .views import DeselectableTreeView
|
||||
from .error_dialog import ErrorMessageBox
|
||||
|
|
@ -60,6 +64,11 @@ __all__ = (
|
|||
"PixmapButton",
|
||||
"SeparatorWidget",
|
||||
|
||||
"VerticalExpandButton",
|
||||
"SquareButton",
|
||||
"RefreshButton",
|
||||
"GoToCurrentButton",
|
||||
|
||||
"DeselectableTreeView",
|
||||
|
||||
"ErrorMessageBox",
|
||||
|
|
|
|||
|
|
@ -6,10 +6,13 @@ import qtawesome
|
|||
|
||||
from openpype.style import (
|
||||
get_objected_colors,
|
||||
get_style_image_path
|
||||
get_style_image_path,
|
||||
get_default_tools_icon_color,
|
||||
)
|
||||
from openpype.lib.attribute_definitions import AbstractAttrDef
|
||||
|
||||
from .lib import get_qta_icon_by_name_and_color
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
|
@ -777,3 +780,77 @@ class SeparatorWidget(QtWidgets.QFrame):
|
|||
self._orientation = orientation
|
||||
|
||||
self._set_size(self._size)
|
||||
|
||||
|
||||
def get_refresh_icon():
|
||||
return get_qta_icon_by_name_and_color(
|
||||
"fa.refresh", get_default_tools_icon_color()
|
||||
)
|
||||
|
||||
|
||||
def get_go_to_current_icon():
|
||||
return get_qta_icon_by_name_and_color(
|
||||
"fa.arrow-down", get_default_tools_icon_color()
|
||||
)
|
||||
|
||||
|
||||
class VerticalExpandButton(QtWidgets.QPushButton):
|
||||
"""Button which is expanding vertically.
|
||||
|
||||
By default, button is a little bit smaller than other widgets like
|
||||
QLineEdit. This button is expanding vertically to match size of
|
||||
other widgets, next to it.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super(VerticalExpandButton, self).__init__(parent)
|
||||
|
||||
sp = self.sizePolicy()
|
||||
sp.setVerticalPolicy(QtWidgets.QSizePolicy.Minimum)
|
||||
self.setSizePolicy(sp)
|
||||
|
||||
|
||||
class SquareButton(QtWidgets.QPushButton):
|
||||
"""Make button square shape.
|
||||
|
||||
Change width to match height on resize.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(SquareButton, self).__init__(*args, **kwargs)
|
||||
|
||||
sp = self.sizePolicy()
|
||||
sp.setVerticalPolicy(QtWidgets.QSizePolicy.Minimum)
|
||||
sp.setHorizontalPolicy(QtWidgets.QSizePolicy.Minimum)
|
||||
self.setSizePolicy(sp)
|
||||
self._ideal_width = None
|
||||
|
||||
def showEvent(self, event):
|
||||
super(SquareButton, self).showEvent(event)
|
||||
self._ideal_width = self.height()
|
||||
self.updateGeometry()
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super(SquareButton, self).resizeEvent(event)
|
||||
self._ideal_width = self.height()
|
||||
self.updateGeometry()
|
||||
|
||||
def sizeHint(self):
|
||||
sh = super(SquareButton, self).sizeHint()
|
||||
ideal_width = self._ideal_width
|
||||
if ideal_width is None:
|
||||
ideal_width = sh.height()
|
||||
sh.setWidth(ideal_width)
|
||||
return sh
|
||||
|
||||
|
||||
class RefreshButton(VerticalExpandButton):
|
||||
def __init__(self, parent=None):
|
||||
super(RefreshButton, self).__init__(parent)
|
||||
self.setIcon(get_refresh_icon())
|
||||
|
||||
|
||||
class GoToCurrentButton(VerticalExpandButton):
|
||||
def __init__(self, parent=None):
|
||||
super(GoToCurrentButton, self).__init__(parent)
|
||||
self.setIcon(get_go_to_current_icon())
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue