diff --git a/client/ayon_core/host/__init__.py b/client/ayon_core/host/__init__.py
index ef5c324028..950c14564e 100644
--- a/client/ayon_core/host/__init__.py
+++ b/client/ayon_core/host/__init__.py
@@ -1,6 +1,8 @@
from .constants import ContextChangeReason
+from .abstract import AbstractHost
from .host import (
HostBase,
+ ContextChangeData,
)
from .interfaces import (
@@ -18,7 +20,10 @@ from .dirmap import HostDirmap
__all__ = (
"ContextChangeReason",
+ "AbstractHost",
+
"HostBase",
+ "ContextChangeData",
"IWorkfileHost",
"WorkfileInfo",
diff --git a/client/ayon_core/host/abstract.py b/client/ayon_core/host/abstract.py
new file mode 100644
index 0000000000..26771aaffa
--- /dev/null
+++ b/client/ayon_core/host/abstract.py
@@ -0,0 +1,96 @@
+from __future__ import annotations
+
+import logging
+from abc import ABC, abstractmethod
+import typing
+from typing import Optional, Any
+
+from .constants import ContextChangeReason
+
+if typing.TYPE_CHECKING:
+ from ayon_core.pipeline import Anatomy
+
+ from .typing import HostContextData
+
+
+class AbstractHost(ABC):
+ """Abstract definition of host implementation."""
+ @property
+ @abstractmethod
+ def log(self) -> logging.Logger:
+ pass
+
+ @property
+ @abstractmethod
+ def name(self) -> str:
+ """Host name."""
+ pass
+
+ @abstractmethod
+ def get_current_context(self) -> HostContextData:
+ """Get the current context of the host.
+
+ Current context is defined by project name, folder path and task name.
+
+ Returns:
+ HostContextData: The current context of the host.
+
+ """
+ pass
+
+ @abstractmethod
+ def set_current_context(
+ self,
+ folder_entity: dict[str, Any],
+ task_entity: dict[str, Any],
+ *,
+ reason: ContextChangeReason = ContextChangeReason.undefined,
+ project_entity: Optional[dict[str, Any]] = None,
+ anatomy: Optional[Anatomy] = None,
+ ) -> HostContextData:
+ """Change context of the host.
+
+ Args:
+ folder_entity (dict[str, Any]): Folder entity.
+ task_entity (dict[str, Any]): Task entity.
+ reason (ContextChangeReason): Reason for change.
+ project_entity (dict[str, Any]): Project entity.
+ anatomy (Anatomy): Anatomy entity.
+
+ """
+ pass
+
+ @abstractmethod
+ def get_current_project_name(self) -> str:
+ """Get the current project name.
+
+ Returns:
+ Optional[str]: The current project name.
+
+ """
+ pass
+
+ @abstractmethod
+ def get_current_folder_path(self) -> Optional[str]:
+ """Get the current folder path.
+
+ Returns:
+ Optional[str]: The current folder path.
+
+ """
+ pass
+
+ @abstractmethod
+ def get_current_task_name(self) -> Optional[str]:
+ """Get the current task name.
+
+ Returns:
+ Optional[str]: The current task name.
+
+ """
+ pass
+
+ @abstractmethod
+ def get_context_title(self) -> str:
+ """Get the context title used in UIs."""
+ pass
diff --git a/client/ayon_core/host/host.py b/client/ayon_core/host/host.py
index 7fc4b19bdd..28cb6b0a09 100644
--- a/client/ayon_core/host/host.py
+++ b/client/ayon_core/host/host.py
@@ -3,26 +3,21 @@ from __future__ import annotations
import os
import logging
import contextlib
-from abc import ABC, abstractmethod
-from dataclasses import dataclass
import typing
from typing import Optional, Any
+from dataclasses import dataclass
import ayon_api
from ayon_core.lib import emit_event
from .constants import ContextChangeReason
+from .abstract import AbstractHost
if typing.TYPE_CHECKING:
from ayon_core.pipeline import Anatomy
- from typing import TypedDict
-
- class HostContextData(TypedDict):
- project_name: str
- folder_path: Optional[str]
- task_name: Optional[str]
+ from .typing import HostContextData
@dataclass
@@ -34,7 +29,7 @@ class ContextChangeData:
anatomy: Anatomy
-class HostBase(ABC):
+class HostBase(AbstractHost):
"""Base of host implementation class.
Host is pipeline implementation of DCC application. This class should help
@@ -109,48 +104,41 @@ class HostBase(ABC):
It is called automatically when 'ayon_core.pipeline.install_host' is
triggered.
- """
+ """
pass
@property
- def log(self):
+ def log(self) -> logging.Logger:
if self._log is None:
self._log = logging.getLogger(self.__class__.__name__)
return self._log
- @property
- @abstractmethod
- def name(self) -> str:
- """Host name."""
-
- pass
-
- def get_current_project_name(self):
+ def get_current_project_name(self) -> str:
"""
Returns:
- Union[str, None]: Current project name.
- """
+ str: Current project name.
- return os.environ.get("AYON_PROJECT_NAME")
+ """
+ return os.environ["AYON_PROJECT_NAME"]
def get_current_folder_path(self) -> Optional[str]:
"""
Returns:
- Union[str, None]: Current asset name.
- """
+ Optional[str]: Current asset name.
+ """
return os.environ.get("AYON_FOLDER_PATH")
def get_current_task_name(self) -> Optional[str]:
"""
Returns:
- Union[str, None]: Current task name.
- """
+ Optional[str]: Current task name.
+ """
return os.environ.get("AYON_TASK_NAME")
- def get_current_context(self) -> "HostContextData":
+ def get_current_context(self) -> HostContextData:
"""Get current context information.
This method should be used to get current context of host. Usage of
@@ -159,10 +147,10 @@ class HostBase(ABC):
can't be caught properly.
Returns:
- Dict[str, Union[str, None]]: Context with 3 keys 'project_name',
- 'folder_path' and 'task_name'. All of them can be 'None'.
- """
+ HostContextData: Current context with 'project_name',
+ 'folder_path' and 'task_name'.
+ """
return {
"project_name": self.get_current_project_name(),
"folder_path": self.get_current_folder_path(),
@@ -177,7 +165,7 @@ class HostBase(ABC):
reason: ContextChangeReason = ContextChangeReason.undefined,
project_entity: Optional[dict[str, Any]] = None,
anatomy: Optional[Anatomy] = None,
- ) -> "HostContextData":
+ ) -> HostContextData:
"""Set current context information.
This method should be used to set current context of host. Usage of
@@ -290,7 +278,7 @@ class HostBase(ABC):
project_name: str,
folder_path: Optional[str],
task_name: Optional[str],
- ) -> "HostContextData":
+ ) -> HostContextData:
"""Emit context change event.
Args:
@@ -302,7 +290,7 @@ class HostBase(ABC):
HostContextData: Data send to context change event.
"""
- data = {
+ data: HostContextData = {
"project_name": project_name,
"folder_path": folder_path,
"task_name": task_name,
diff --git a/client/ayon_core/host/interfaces/interfaces.py b/client/ayon_core/host/interfaces/interfaces.py
index a41dffe92a..6f9a3d8c87 100644
--- a/client/ayon_core/host/interfaces/interfaces.py
+++ b/client/ayon_core/host/interfaces/interfaces.py
@@ -1,9 +1,11 @@
from abc import abstractmethod
+from ayon_core.host.abstract import AbstractHost
+
from .exceptions import MissingMethodsError
-class ILoadHost:
+class ILoadHost(AbstractHost):
"""Implementation requirements to be able use reference of representations.
The load plugins can do referencing even without implementation of methods
@@ -24,7 +26,7 @@ class ILoadHost:
loading. Checks only existence of methods.
Args:
- Union[ModuleType, HostBase]: Object of host where to look for
+ Union[ModuleType, AbstractHost]: Object of host where to look for
required methods.
Returns:
@@ -46,7 +48,7 @@ class ILoadHost:
"""Validate implemented methods of "old type" host for load workflow.
Args:
- Union[ModuleType, HostBase]: Object of host to validate.
+ Union[ModuleType, AbstractHost]: Object of host to validate.
Raises:
MissingMethodsError: If there are missing methods on host
@@ -83,7 +85,7 @@ class ILoadHost:
return self.get_containers()
-class IPublishHost:
+class IPublishHost(AbstractHost):
"""Functions related to new creation system in new publisher.
New publisher is not storing information only about each created instance
@@ -99,7 +101,7 @@ class IPublishHost:
new publish creation. Checks only existence of methods.
Args:
- Union[ModuleType, HostBase]: Host module where to look for
+ Union[ModuleType, AbstractHost]: Host module where to look for
required methods.
Returns:
@@ -127,7 +129,7 @@ class IPublishHost:
"""Validate implemented methods of "old type" host.
Args:
- Union[ModuleType, HostBase]: Host module to validate.
+ Union[ModuleType, AbstractHost]: Host module to validate.
Raises:
MissingMethodsError: If there are missing methods on host
diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py
index 82d71d152a..93aad4c117 100644
--- a/client/ayon_core/host/interfaces/workfiles.py
+++ b/client/ayon_core/host/interfaces/workfiles.py
@@ -15,6 +15,7 @@ import arrow
from ayon_core.lib import emit_event
from ayon_core.settings import get_project_settings
+from ayon_core.host.abstract import AbstractHost
from ayon_core.host.constants import ContextChangeReason
if typing.TYPE_CHECKING:
@@ -821,7 +822,7 @@ class PublishedWorkfileInfo:
return PublishedWorkfileInfo(**data)
-class IWorkfileHost:
+class IWorkfileHost(AbstractHost):
"""Implementation requirements to be able to use workfiles utils and tool.
Some of the methods are pre-implemented as they generally do the same in
diff --git a/client/ayon_core/host/typing.py b/client/ayon_core/host/typing.py
new file mode 100644
index 0000000000..a51460713b
--- /dev/null
+++ b/client/ayon_core/host/typing.py
@@ -0,0 +1,7 @@
+from typing import Optional, TypedDict
+
+
+class HostContextData(TypedDict):
+ project_name: str
+ folder_path: Optional[str]
+ task_name: Optional[str]
diff --git a/client/ayon_core/pipeline/__init__.py b/client/ayon_core/pipeline/__init__.py
index 137736c302..f2ec952cd6 100644
--- a/client/ayon_core/pipeline/__init__.py
+++ b/client/ayon_core/pipeline/__init__.py
@@ -19,11 +19,7 @@ from .create import (
CreatedInstance,
CreatorError,
- LegacyCreator,
- legacy_create,
-
discover_creator_plugins,
- discover_legacy_creator_plugins,
register_creator_plugin,
deregister_creator_plugin,
register_creator_plugin_path,
@@ -141,12 +137,7 @@ __all__ = (
"CreatorError",
- # - legacy creation
- "LegacyCreator",
- "legacy_create",
-
"discover_creator_plugins",
- "discover_legacy_creator_plugins",
"register_creator_plugin",
"deregister_creator_plugin",
"register_creator_plugin_path",
diff --git a/client/ayon_core/pipeline/context_tools.py b/client/ayon_core/pipeline/context_tools.py
index 423e8f7216..0589eeb49f 100644
--- a/client/ayon_core/pipeline/context_tools.py
+++ b/client/ayon_core/pipeline/context_tools.py
@@ -13,7 +13,7 @@ import pyblish.api
from pyblish.lib import MessageHandler
from ayon_core import AYON_CORE_ROOT
-from ayon_core.host import HostBase
+from ayon_core.host import AbstractHost
from ayon_core.lib import (
is_in_tests,
initialize_ayon_connection,
@@ -100,16 +100,16 @@ def registered_root():
return _registered_root["_"]
-def install_host(host: HostBase) -> None:
+def install_host(host: AbstractHost) -> None:
"""Install `host` into the running Python session.
Args:
- host (HostBase): A host interface object.
+ host (AbstractHost): A host interface object.
"""
- if not isinstance(host, HostBase):
+ if not isinstance(host, AbstractHost):
log.error(
- f"Host must be a subclass of 'HostBase', got '{type(host)}'."
+ f"Host must be a subclass of 'AbstractHost', got '{type(host)}'."
)
global _is_installed
@@ -310,7 +310,7 @@ def get_current_host_name():
"""
host = registered_host()
- if isinstance(host, HostBase):
+ if isinstance(host, AbstractHost):
return host.name
return os.environ.get("AYON_HOST_NAME")
@@ -346,28 +346,28 @@ def get_global_context():
def get_current_context():
host = registered_host()
- if isinstance(host, HostBase):
+ if isinstance(host, AbstractHost):
return host.get_current_context()
return get_global_context()
def get_current_project_name():
host = registered_host()
- if isinstance(host, HostBase):
+ if isinstance(host, AbstractHost):
return host.get_current_project_name()
return get_global_context()["project_name"]
def get_current_folder_path():
host = registered_host()
- if isinstance(host, HostBase):
+ if isinstance(host, AbstractHost):
return host.get_current_folder_path()
return get_global_context()["folder_path"]
def get_current_task_name():
host = registered_host()
- if isinstance(host, HostBase):
+ if isinstance(host, AbstractHost):
return host.get_current_task_name()
return get_global_context()["task_name"]
diff --git a/client/ayon_core/pipeline/create/__init__.py b/client/ayon_core/pipeline/create/__init__.py
index ced43528eb..edb1b12cd4 100644
--- a/client/ayon_core/pipeline/create/__init__.py
+++ b/client/ayon_core/pipeline/create/__init__.py
@@ -21,12 +21,14 @@ from .exceptions import (
TemplateFillError,
)
from .structures import (
+ ParentFlags,
CreatedInstance,
ConvertorItem,
AttributeValues,
CreatorAttributeValues,
PublishAttributeValues,
PublishAttributes,
+ InstanceContextInfo,
)
from .utils import (
get_last_versions_for_instances,
@@ -44,9 +46,6 @@ from .creator_plugins import (
AutoCreator,
HiddenCreator,
- discover_legacy_creator_plugins,
- get_legacy_creator_by_name,
-
discover_creator_plugins,
register_creator_plugin,
deregister_creator_plugin,
@@ -58,11 +57,6 @@ from .creator_plugins import (
from .context import CreateContext
-from .legacy_create import (
- LegacyCreator,
- legacy_create,
-)
-
__all__ = (
"PRODUCT_NAME_ALLOWED_SYMBOLS",
@@ -85,12 +79,14 @@ __all__ = (
"TaskNotSetError",
"TemplateFillError",
+ "ParentFlags",
"CreatedInstance",
"ConvertorItem",
"AttributeValues",
"CreatorAttributeValues",
"PublishAttributeValues",
"PublishAttributes",
+ "InstanceContextInfo",
"get_last_versions_for_instances",
"get_next_versions_for_instances",
@@ -105,9 +101,6 @@ __all__ = (
"AutoCreator",
"HiddenCreator",
- "discover_legacy_creator_plugins",
- "get_legacy_creator_by_name",
-
"discover_creator_plugins",
"register_creator_plugin",
"deregister_creator_plugin",
@@ -117,7 +110,4 @@ __all__ = (
"cache_and_get_instances",
"CreateContext",
-
- "LegacyCreator",
- "legacy_create",
)
diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py
index 929cc59d2a..c9b3178fe4 100644
--- a/client/ayon_core/pipeline/create/context.py
+++ b/client/ayon_core/pipeline/create/context.py
@@ -41,7 +41,12 @@ from .exceptions import (
HostMissRequiredMethod,
)
from .changes import TrackChangesItem
-from .structures import PublishAttributes, ConvertorItem, InstanceContextInfo
+from .structures import (
+ PublishAttributes,
+ ConvertorItem,
+ InstanceContextInfo,
+ ParentFlags,
+)
from .creator_plugins import (
Creator,
AutoCreator,
@@ -49,15 +54,12 @@ from .creator_plugins import (
discover_convertor_plugins,
)
if typing.TYPE_CHECKING:
- from ayon_core.host import HostBase
from ayon_core.lib import AbstractAttrDef
from ayon_core.lib.events import EventCallback, Event
from .structures import CreatedInstance
from .creator_plugins import BaseCreator
- class PublishHost(HostBase, IPublishHost):
- pass
# Import of functions and classes that were moved to different file
# TODO Should be removed in future release - Added 24/08/28, 0.4.3-dev.1
@@ -80,6 +82,7 @@ INSTANCE_ADDED_TOPIC = "instances.added"
INSTANCE_REMOVED_TOPIC = "instances.removed"
VALUE_CHANGED_TOPIC = "values.changed"
INSTANCE_REQUIREMENT_CHANGED_TOPIC = "instance.requirement.changed"
+INSTANCE_PARENT_CHANGED_TOPIC = "instance.parent.changed"
PRE_CREATE_ATTR_DEFS_CHANGED_TOPIC = "pre.create.attr.defs.changed"
CREATE_ATTR_DEFS_CHANGED_TOPIC = "create.attr.defs.changed"
PUBLISH_ATTR_DEFS_CHANGED_TOPIC = "publish.attr.defs.changed"
@@ -163,7 +166,7 @@ class CreateContext:
context which should be handled by host.
Args:
- host (PublishHost): Host implementation which handles implementation
+ host (IPublishHost): Host implementation which handles implementation
and global metadata.
headless (bool): Context is created out of UI (Current not used).
reset (bool): Reset context on initialization.
@@ -173,7 +176,7 @@ class CreateContext:
def __init__(
self,
- host: "PublishHost",
+ host: IPublishHost,
headless: bool = False,
reset: bool = True,
discover_publish_plugins: bool = True,
@@ -262,6 +265,8 @@ class CreateContext:
# - right now used only for 'mandatory' but can be extended
# in future
"requirement_change": BulkInfo(),
+ # Instance parent changed
+ "parent_change": BulkInfo(),
}
self._bulk_order = []
@@ -1083,6 +1088,35 @@ class CreateContext:
INSTANCE_REQUIREMENT_CHANGED_TOPIC, callback
)
+ def add_instance_parent_change_callback(
+ self, callback: Callable
+ ) -> "EventCallback":
+ """Register callback to listen to instance parent changes.
+
+ Instance changed parent or parent flags.
+
+ Data structure of event:
+
+ ```python
+ {
+ "instances": [CreatedInstance, ...],
+ "create_context": CreateContext
+ }
+ ```
+
+ Args:
+ callback (Callable): Callback function that will be called when
+ instance requirement changed.
+
+ Returns:
+ EventCallback: Created callback object which can be used to
+ stop listening.
+
+ """
+ return self._event_hub.add_callback(
+ INSTANCE_PARENT_CHANGED_TOPIC, callback
+ )
+
def context_data_to_store(self) -> dict[str, Any]:
"""Data that should be stored by host function.
@@ -1364,6 +1398,13 @@ class CreateContext:
) as bulk_info:
yield bulk_info
+ @contextmanager
+ def bulk_instance_parent_change(self, sender: Optional[str] = None):
+ with self._bulk_context(
+ "parent_change", sender
+ ) as bulk_info:
+ yield bulk_info
+
@contextmanager
def bulk_publish_attr_defs_change(self, sender: Optional[str] = None):
with self._bulk_context("publish_attrs_change", sender) as bulk_info:
@@ -1444,6 +1485,19 @@ class CreateContext:
with self.bulk_instance_requirement_change() as bulk_item:
bulk_item.append(instance_id)
+ def instance_parent_changed(self, instance_id: str) -> None:
+ """Instance parent changed.
+
+ Triggered by `CreatedInstance`.
+
+ Args:
+ instance_id (Optional[str]): Instance id.
+
+ """
+ if self._is_instance_events_ready(instance_id):
+ with self.bulk_instance_parent_change() as bulk_item:
+ bulk_item.append(instance_id)
+
# --- context change callbacks ---
def publish_attribute_value_changed(
self, plugin_name: str, value: dict[str, Any]
@@ -2046,63 +2100,97 @@ class CreateContext:
sender (Optional[str]): Sender of the event.
"""
+ instance_ids_by_parent_id = collections.defaultdict(set)
+ for instance in self.instances:
+ instance_ids_by_parent_id[instance.parent_instance_id].add(
+ instance.id
+ )
+
+ instances_to_remove = list(instances)
+ ids_to_remove = {
+ instance.id
+ for instance in instances_to_remove
+ }
+ _queue = collections.deque()
+ _queue.extend(instances_to_remove)
+ # Add children with parent lifetime flag
+ while _queue:
+ instance = _queue.popleft()
+ ids_to_remove.add(instance.id)
+ children_ids = instance_ids_by_parent_id[instance.id]
+ for children_id in children_ids:
+ if children_id in ids_to_remove:
+ continue
+ instance = self._instances_by_id[children_id]
+ if instance.parent_flags & ParentFlags.parent_lifetime:
+ instances_to_remove.append(instance)
+ ids_to_remove.add(instance.id)
+ _queue.append(instance)
+
instances_by_identifier = collections.defaultdict(list)
- for instance in instances:
+ for instance in instances_to_remove:
identifier = instance.creator_identifier
instances_by_identifier[identifier].append(instance)
# Just remove instances from context if creator is not available
missing_creators = set(instances_by_identifier) - set(self.creators)
- instances = []
+ miss_creator_instances = []
for identifier in missing_creators:
- instances.extend(
- instance
- for instance in instances_by_identifier[identifier]
- )
+ miss_creator_instances.extend(instances_by_identifier[identifier])
- self._remove_instances(instances, sender)
+ with self.bulk_remove_instances(sender):
+ self._remove_instances(miss_creator_instances, sender)
- error_message = "Instances removement of creator \"{}\" failed. {}"
- failed_info = []
- # Remove instances by creator plugin order
- for creator in self.get_sorted_creators(
- instances_by_identifier.keys()
- ):
- identifier = creator.identifier
- creator_instances = instances_by_identifier[identifier]
+ error_message = "Instances removement of creator \"{}\" failed. {}"
+ failed_info = []
+ # Remove instances by creator plugin order
+ for creator in self.get_sorted_creators(
+ instances_by_identifier.keys()
+ ):
+ identifier = creator.identifier
+ # Filter instances by current state of 'CreateContext'
+ # - in case instances were already removed as subroutine of
+ # previous create plugin.
+ creator_instances = [
+ instance
+ for instance in instances_by_identifier[identifier]
+ if instance.id in self._instances_by_id
+ ]
+ if not creator_instances:
+ continue
- label = creator.label
- failed = False
- add_traceback = False
- exc_info = None
- try:
- creator.remove_instances(creator_instances)
+ label = creator.label
+ failed = False
+ add_traceback = False
+ exc_info = None
+ try:
+ creator.remove_instances(creator_instances)
- except CreatorError:
- failed = True
- exc_info = sys.exc_info()
- self.log.warning(
- error_message.format(identifier, exc_info[1])
- )
-
- except (KeyboardInterrupt, SystemExit):
- raise
-
- except: # noqa: E722
- failed = True
- add_traceback = True
- exc_info = sys.exc_info()
- self.log.warning(
- error_message.format(identifier, ""),
- exc_info=True
- )
-
- if failed:
- failed_info.append(
- prepare_failed_creator_operation_info(
- identifier, label, exc_info, add_traceback
+ except CreatorError:
+ failed = True
+ exc_info = sys.exc_info()
+ self.log.warning(
+ error_message.format(identifier, exc_info[1])
+ )
+
+ except (KeyboardInterrupt, SystemExit):
+ raise
+
+ except: # noqa: E722
+ failed = True
+ add_traceback = True
+ exc_info = sys.exc_info()
+ self.log.warning(
+ error_message.format(identifier, ""),
+ exc_info=True
+ )
+
+ if failed:
+ failed_info.append(
+ prepare_failed_creator_operation_info(
+ identifier, label, exc_info, add_traceback
+ )
)
- )
if failed_info:
raise CreatorsRemoveFailed(failed_info)
@@ -2305,6 +2393,8 @@ class CreateContext:
self._bulk_publish_attrs_change_finished(data, sender)
elif key == "requirement_change":
self._bulk_instance_requirement_change_finished(data, sender)
+ elif key == "parent_change":
+ self._bulk_instance_parent_change_finished(data, sender)
def _bulk_add_instances_finished(
self,
@@ -2518,3 +2608,22 @@ class CreateContext:
{"instances": instances},
sender,
)
+
+ def _bulk_instance_parent_change_finished(
+ self,
+ instance_ids: list[str],
+ sender: Optional[str],
+ ):
+ if not instance_ids:
+ return
+
+ instances = [
+ self.get_instance_by_id(instance_id)
+ for instance_id in set(instance_ids)
+ ]
+
+ self._emit_event(
+ INSTANCE_PARENT_CHANGED_TOPIC,
+ {"instances": instances},
+ sender,
+ )
diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py
index cbc06145fb..7573589b82 100644
--- a/client/ayon_core/pipeline/create/creator_plugins.py
+++ b/client/ayon_core/pipeline/create/creator_plugins.py
@@ -6,7 +6,6 @@ from typing import TYPE_CHECKING, Optional, Dict, Any
from abc import ABC, abstractmethod
-from ayon_core.settings import get_project_settings
from ayon_core.lib import Logger, get_version_from_path
from ayon_core.pipeline.plugin_discover import (
discover,
@@ -20,7 +19,6 @@ from ayon_core.pipeline.staging_dir import get_staging_dir_info, StagingDir
from .constants import DEFAULT_VARIANT_VALUE
from .product_name import get_product_name
from .utils import get_next_versions_for_instances
-from .legacy_create import LegacyCreator
from .structures import CreatedInstance
if TYPE_CHECKING:
@@ -975,62 +973,10 @@ def discover_convertor_plugins(*args, **kwargs):
return discover(ProductConvertorPlugin, *args, **kwargs)
-def discover_legacy_creator_plugins():
- from ayon_core.pipeline import get_current_project_name
-
- log = Logger.get_logger("CreatorDiscover")
-
- plugins = discover(LegacyCreator)
- project_name = get_current_project_name()
- project_settings = get_project_settings(project_name)
- for plugin in plugins:
- try:
- plugin.apply_settings(project_settings)
- except Exception:
- log.warning(
- "Failed to apply settings to creator {}".format(
- plugin.__name__
- ),
- exc_info=True
- )
- return plugins
-
-
-def get_legacy_creator_by_name(creator_name, case_sensitive=False):
- """Find creator plugin by name.
-
- Args:
- creator_name (str): Name of creator class that should be returned.
- case_sensitive (bool): Match of creator plugin name is case sensitive.
- Set to `False` by default.
-
- Returns:
- Creator: Return first matching plugin or `None`.
- """
-
- # Lower input creator name if is not case sensitive
- if not case_sensitive:
- creator_name = creator_name.lower()
-
- for creator_plugin in discover_legacy_creator_plugins():
- _creator_name = creator_plugin.__name__
-
- # Lower creator plugin name if is not case sensitive
- if not case_sensitive:
- _creator_name = _creator_name.lower()
-
- if _creator_name == creator_name:
- return creator_plugin
- return None
-
-
def register_creator_plugin(plugin):
if issubclass(plugin, BaseCreator):
register_plugin(BaseCreator, plugin)
- elif issubclass(plugin, LegacyCreator):
- register_plugin(LegacyCreator, plugin)
-
elif issubclass(plugin, ProductConvertorPlugin):
register_plugin(ProductConvertorPlugin, plugin)
@@ -1039,22 +985,17 @@ def deregister_creator_plugin(plugin):
if issubclass(plugin, BaseCreator):
deregister_plugin(BaseCreator, plugin)
- elif issubclass(plugin, LegacyCreator):
- deregister_plugin(LegacyCreator, plugin)
-
elif issubclass(plugin, ProductConvertorPlugin):
deregister_plugin(ProductConvertorPlugin, plugin)
def register_creator_plugin_path(path):
register_plugin_path(BaseCreator, path)
- register_plugin_path(LegacyCreator, path)
register_plugin_path(ProductConvertorPlugin, path)
def deregister_creator_plugin_path(path):
deregister_plugin_path(BaseCreator, path)
- deregister_plugin_path(LegacyCreator, path)
deregister_plugin_path(ProductConvertorPlugin, path)
diff --git a/client/ayon_core/pipeline/create/legacy_create.py b/client/ayon_core/pipeline/create/legacy_create.py
deleted file mode 100644
index f6427d9bd1..0000000000
--- a/client/ayon_core/pipeline/create/legacy_create.py
+++ /dev/null
@@ -1,216 +0,0 @@
-"""Create workflow moved from avalon-core repository.
-
-Renamed classes and functions
-- 'Creator' -> 'LegacyCreator'
-- 'create' -> 'legacy_create'
-"""
-
-import os
-import logging
-import collections
-
-from ayon_core.pipeline.constants import AYON_INSTANCE_ID
-
-from .product_name import get_product_name
-
-
-class LegacyCreator:
- """Determine how assets are created"""
- label = None
- product_type = None
- defaults = None
- maintain_selection = True
- enabled = True
-
- dynamic_product_name_keys = []
-
- log = logging.getLogger("LegacyCreator")
- log.propagate = True
-
- def __init__(self, name, folder_path, options=None, data=None):
- self.name = name # For backwards compatibility
- self.options = options
-
- # Default data
- self.data = collections.OrderedDict()
- # TODO use 'AYON_INSTANCE_ID' when all hosts support it
- self.data["id"] = AYON_INSTANCE_ID
- self.data["productType"] = self.product_type
- self.data["folderPath"] = folder_path
- self.data["productName"] = name
- self.data["active"] = True
-
- self.data.update(data or {})
-
- @classmethod
- def apply_settings(cls, project_settings):
- """Apply AYON settings to a plugin class."""
-
- host_name = os.environ.get("AYON_HOST_NAME")
- plugin_type = "create"
- plugin_type_settings = (
- project_settings
- .get(host_name, {})
- .get(plugin_type, {})
- )
- global_type_settings = (
- project_settings
- .get("core", {})
- .get(plugin_type, {})
- )
- if not global_type_settings and not plugin_type_settings:
- return
-
- plugin_name = cls.__name__
-
- plugin_settings = None
- # Look for plugin settings in host specific settings
- if plugin_name in plugin_type_settings:
- plugin_settings = plugin_type_settings[plugin_name]
-
- # Look for plugin settings in global settings
- elif plugin_name in global_type_settings:
- plugin_settings = global_type_settings[plugin_name]
-
- if not plugin_settings:
- return
-
- cls.log.debug(">>> We have preset for {}".format(plugin_name))
- for option, value in plugin_settings.items():
- if option == "enabled" and value is False:
- cls.log.debug(" - is disabled by preset")
- else:
- cls.log.debug(" - setting `{}`: `{}`".format(option, value))
- setattr(cls, option, value)
-
- def process(self):
- pass
-
- @classmethod
- def get_dynamic_data(
- cls, project_name, folder_entity, task_entity, variant, host_name
- ):
- """Return dynamic data for current Creator plugin.
-
- By default return keys from `dynamic_product_name_keys` attribute
- as mapping to keep formatted template unchanged.
-
- ```
- dynamic_product_name_keys = ["my_key"]
- ---
- output = {
- "my_key": "{my_key}"
- }
- ```
-
- Dynamic keys may override default Creator keys (productType, task,
- folderPath, ...) but do it wisely if you need.
-
- All of keys will be converted into 3 variants unchanged, capitalized
- and all upper letters. Because of that are all keys lowered.
-
- This method can be modified to prefill some values just keep in mind it
- is class method.
-
- Args:
- project_name (str): Context's project name.
- folder_entity (dict[str, Any]): Folder entity.
- task_entity (dict[str, Any]): Task entity.
- variant (str): What is entered by user in creator tool.
- host_name (str): Name of host.
-
- Returns:
- dict: Fill data for product name template.
- """
- dynamic_data = {}
- for key in cls.dynamic_product_name_keys:
- key = key.lower()
- dynamic_data[key] = "{" + key + "}"
- return dynamic_data
-
- @classmethod
- def get_product_name(
- cls, project_name, folder_entity, task_entity, variant, host_name=None
- ):
- """Return product name created with entered arguments.
-
- Logic extracted from Creator tool. This method should give ability
- to get product name without the tool.
-
- TODO: Maybe change `variant` variable.
-
- By default is output concatenated product type with variant.
-
- Args:
- project_name (str): Context's project name.
- folder_entity (dict[str, Any]): Folder entity.
- task_entity (dict[str, Any]): Task entity.
- variant (str): What is entered by user in creator tool.
- host_name (str): Name of host.
-
- Returns:
- str: Formatted product name with entered arguments. Should match
- config's logic.
- """
-
- dynamic_data = cls.get_dynamic_data(
- project_name, folder_entity, task_entity, variant, host_name
- )
- task_name = task_type = None
- if task_entity:
- task_name = task_entity["name"]
- task_type = task_entity["taskType"]
- return get_product_name(
- project_name,
- task_name,
- task_type,
- host_name,
- cls.product_type,
- variant,
- dynamic_data=dynamic_data
- )
-
-
-def legacy_create(
- Creator, product_name, folder_path, options=None, data=None
-):
- """Create a new instance
-
- Associate nodes with a product name and type. These nodes are later
- validated, according to their `product type`, and integrated into the
- shared environment, relative their `productName`.
-
- Data relative each product type, along with default data, are imprinted
- into the resulting objectSet. This data is later used by extractors
- and finally asset browsers to help identify the origin of the asset.
-
- Arguments:
- Creator (Creator): Class of creator.
- product_name (str): Name of product.
- folder_path (str): Folder path.
- options (dict, optional): Additional options from GUI.
- data (dict, optional): Additional data from GUI.
-
- Raises:
- NameError on `productName` already exists
- KeyError on invalid dynamic property
- RuntimeError on host error
-
- Returns:
- Name of instance
-
- """
- from ayon_core.pipeline import registered_host
-
- host = registered_host()
- plugin = Creator(product_name, folder_path, options, data)
-
- if plugin.maintain_selection is True:
- with host.maintained_selection():
- print("Running %s with maintained selection" % plugin)
- instance = plugin.process()
- return instance
-
- print("Running %s" % plugin)
- instance = plugin.process()
- return instance
diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py
index a4c68d2502..b2be377b42 100644
--- a/client/ayon_core/pipeline/create/structures.py
+++ b/client/ayon_core/pipeline/create/structures.py
@@ -1,6 +1,7 @@
import copy
import collections
from uuid import uuid4
+from enum import Enum
import typing
from typing import Optional, Dict, List, Any
@@ -22,6 +23,23 @@ if typing.TYPE_CHECKING:
from .creator_plugins import BaseCreator
+class IntEnum(int, Enum):
+ """An int-based Enum class that allows for int comparison."""
+
+ def __int__(self) -> int:
+ return self.value
+
+
+class ParentFlags(IntEnum):
+ # Delete instance if parent is deleted
+ parent_lifetime = 1
+ # Active state is propagated from parent to children
+ # - the active state is propagated in collection phase
+ # NOTE It might be helpful to have a function that would return "real"
+ # active state for instances
+ share_active = 1 << 1
+
+
class ConvertorItem:
"""Item representing convertor plugin.
@@ -507,7 +525,9 @@ class CreatedInstance:
if transient_data is None:
transient_data = {}
self._transient_data = transient_data
- self._is_mandatory = False
+ self._is_mandatory: bool = False
+ self._parent_instance_id: Optional[str] = None
+ self._parent_flags: int = 0
# Create a copy of passed data to avoid changing them on the fly
data = copy.deepcopy(data or {})
@@ -752,6 +772,39 @@ class CreatedInstance:
self["active"] = True
self._create_context.instance_requirement_changed(self.id)
+ @property
+ def parent_instance_id(self) -> Optional[str]:
+ return self._parent_instance_id
+
+ @property
+ def parent_flags(self) -> int:
+ return self._parent_flags
+
+ def set_parent(
+ self, instance_id: Optional[str], flags: int
+ ) -> None:
+ """Set parent instance id and parenting flags.
+
+ Args:
+ instance_id (Optional[str]): Parent instance id.
+ flags (int): Parenting flags.
+
+ """
+ changed = False
+ if instance_id != self._parent_instance_id:
+ changed = True
+ self._parent_instance_id = instance_id
+
+ if flags is None:
+ flags = 0
+
+ if self._parent_flags != flags:
+ self._parent_flags = flags
+ changed = True
+
+ if changed:
+ self._create_context.instance_parent_changed(self.id)
+
def changes(self):
"""Calculate and return changes."""
diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py
index e2add99752..52e27baa80 100644
--- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py
+++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py
@@ -30,7 +30,7 @@ from ayon_api import (
)
from ayon_core.settings import get_project_settings
-from ayon_core.host import IWorkfileHost, HostBase
+from ayon_core.host import IWorkfileHost, AbstractHost
from ayon_core.lib import (
Logger,
StringTemplate,
@@ -54,7 +54,6 @@ from ayon_core.pipeline.plugin_discover import (
)
from ayon_core.pipeline.create import (
- discover_legacy_creator_plugins,
CreateContext,
HiddenCreator,
)
@@ -127,15 +126,14 @@ class AbstractTemplateBuilder(ABC):
placeholder population.
Args:
- host (Union[HostBase, ModuleType]): Implementation of host.
+ host (Union[AbstractHost, ModuleType]): Implementation of host.
"""
_log = None
- use_legacy_creators = False
def __init__(self, host):
# Get host name
- if isinstance(host, HostBase):
+ if isinstance(host, AbstractHost):
host_name = host.name
else:
host_name = os.environ.get("AYON_HOST_NAME")
@@ -163,24 +161,24 @@ class AbstractTemplateBuilder(ABC):
@property
def project_name(self):
- if isinstance(self._host, HostBase):
+ if isinstance(self._host, AbstractHost):
return self._host.get_current_project_name()
return os.getenv("AYON_PROJECT_NAME")
@property
def current_folder_path(self):
- if isinstance(self._host, HostBase):
+ if isinstance(self._host, AbstractHost):
return self._host.get_current_folder_path()
return os.getenv("AYON_FOLDER_PATH")
@property
def current_task_name(self):
- if isinstance(self._host, HostBase):
+ if isinstance(self._host, AbstractHost):
return self._host.get_current_task_name()
return os.getenv("AYON_TASK_NAME")
def get_current_context(self):
- if isinstance(self._host, HostBase):
+ if isinstance(self._host, AbstractHost):
return self._host.get_current_context()
return {
"project_name": self.project_name,
@@ -256,7 +254,7 @@ class AbstractTemplateBuilder(ABC):
"""Access to host implementation.
Returns:
- Union[HostBase, ModuleType]: Implementation of host.
+ Union[AbstractHost, ModuleType]: Implementation of host.
"""
return self._host
@@ -321,19 +319,6 @@ class AbstractTemplateBuilder(ABC):
return list(get_folders(project_name, folder_ids=linked_folder_ids))
- def _collect_legacy_creators(self):
- creators_by_name = {}
- for creator in discover_legacy_creator_plugins():
- if not creator.enabled:
- continue
- creator_name = creator.__name__
- if creator_name in creators_by_name:
- raise KeyError(
- "Duplicated creator name {} !".format(creator_name)
- )
- creators_by_name[creator_name] = creator
- self._creators_by_name = creators_by_name
-
def _collect_creators(self):
self._creators_by_name = {
identifier: creator
@@ -345,10 +330,7 @@ class AbstractTemplateBuilder(ABC):
def get_creators_by_name(self):
if self._creators_by_name is None:
- if self.use_legacy_creators:
- self._collect_legacy_creators()
- else:
- self._collect_creators()
+ self._collect_creators()
return self._creators_by_name
@@ -1938,8 +1920,6 @@ class PlaceholderCreateMixin(object):
pre_create_data (dict): dictionary of configuration from Creator
configuration in UI
"""
-
- legacy_create = self.builder.use_legacy_creators
creator_name = placeholder.data["creator"]
create_variant = placeholder.data["create_variant"]
active = placeholder.data.get("active")
@@ -1979,20 +1959,14 @@ class PlaceholderCreateMixin(object):
# compile product name from variant
try:
- if legacy_create:
- creator_instance = creator_plugin(
- product_name,
- folder_path
- ).process()
- else:
- creator_instance = self.builder.create_context.create(
- creator_plugin.identifier,
- create_variant,
- folder_entity,
- task_entity,
- pre_create_data=pre_create_data,
- active=active
- )
+ creator_instance = self.builder.create_context.create(
+ creator_plugin.identifier,
+ create_variant,
+ folder_entity,
+ task_entity,
+ pre_create_data=pre_create_data,
+ active=active
+ )
except: # noqa: E722
failed = True
diff --git a/client/ayon_core/plugins/publish/collect_from_create_context.py b/client/ayon_core/plugins/publish/collect_from_create_context.py
index b99866fed9..5e0ecbdff4 100644
--- a/client/ayon_core/plugins/publish/collect_from_create_context.py
+++ b/client/ayon_core/plugins/publish/collect_from_create_context.py
@@ -2,11 +2,13 @@
"""
import os
+import collections
+
import pyblish.api
from ayon_core.host import IPublishHost
from ayon_core.pipeline import registered_host
-from ayon_core.pipeline.create import CreateContext
+from ayon_core.pipeline.create import CreateContext, ParentFlags
class CollectFromCreateContext(pyblish.api.ContextPlugin):
@@ -36,18 +38,51 @@ class CollectFromCreateContext(pyblish.api.ContextPlugin):
if project_name:
context.data["projectName"] = project_name
+ # Separate root instances and parented instances
+ instances_by_parent_id = collections.defaultdict(list)
+ root_instances = []
for created_instance in create_context.instances:
+ parent_id = created_instance.parent_instance_id
+ if parent_id is None:
+ root_instances.append(created_instance)
+ else:
+ instances_by_parent_id[parent_id].append(created_instance)
+
+ # Traverse instances from top to bottom
+ # - All instances without an existing parent are automatically
+ # eliminated
+ filtered_instances = []
+ _queue = collections.deque()
+ _queue.append((root_instances, True))
+ while _queue:
+ created_instances, parent_is_active = _queue.popleft()
+ for created_instance in created_instances:
+ is_active = created_instance["active"]
+ # Use a parent's active state if parent flags defines that
+ if (
+ created_instance.parent_flags & ParentFlags.share_active
+ and is_active
+ ):
+ is_active = parent_is_active
+
+ if is_active:
+ filtered_instances.append(created_instance)
+
+ children = instances_by_parent_id[created_instance.id]
+ if children:
+ _queue.append((children, is_active))
+
+ for created_instance in filtered_instances:
instance_data = created_instance.data_to_store()
- if instance_data["active"]:
- thumbnail_path = thumbnail_paths_by_instance_id.get(
- created_instance.id
- )
- self.create_instance(
- context,
- instance_data,
- created_instance.transient_data,
- thumbnail_path
- )
+ thumbnail_path = thumbnail_paths_by_instance_id.get(
+ created_instance.id
+ )
+ self.create_instance(
+ context,
+ instance_data,
+ created_instance.transient_data,
+ thumbnail_path
+ )
# Update global data to context
context.data.update(create_context.context_data_to_store())
diff --git a/client/ayon_core/style/data.json b/client/ayon_core/style/data.json
index 24629ec085..56d2190e09 100644
--- a/client/ayon_core/style/data.json
+++ b/client/ayon_core/style/data.json
@@ -97,6 +97,7 @@
},
"publisher": {
"error": "#AA5050",
+ "disabled": "#5b6779",
"crash": "#FF6432",
"success": "#458056",
"warning": "#ffc671",
diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css
index b26d36fb7e..0d057beb7b 100644
--- a/client/ayon_core/style/style.css
+++ b/client/ayon_core/style/style.css
@@ -1153,6 +1153,10 @@ PixmapButton:disabled {
color: {color:publisher:error};
}
+#ListViewProductName[state="disabled"] {
+ color: {color:publisher:disabled};
+}
+
#PublishInfoFrame {
background: {color:bg};
border-radius: 0.3em;
diff --git a/client/ayon_core/tools/creator/__init__.py b/client/ayon_core/tools/creator/__init__.py
deleted file mode 100644
index 585b8bdf80..0000000000
--- a/client/ayon_core/tools/creator/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-from .window import (
- show,
- CreatorWindow
-)
-
-__all__ = (
- "show",
- "CreatorWindow"
-)
diff --git a/client/ayon_core/tools/creator/constants.py b/client/ayon_core/tools/creator/constants.py
deleted file mode 100644
index ec555fbe9c..0000000000
--- a/client/ayon_core/tools/creator/constants.py
+++ /dev/null
@@ -1,8 +0,0 @@
-from qtpy import QtCore
-
-
-PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 1
-ITEM_ID_ROLE = QtCore.Qt.UserRole + 2
-
-SEPARATOR = "---"
-SEPARATORS = {"---", "---separator---"}
diff --git a/client/ayon_core/tools/creator/model.py b/client/ayon_core/tools/creator/model.py
deleted file mode 100644
index bf6c7380a1..0000000000
--- a/client/ayon_core/tools/creator/model.py
+++ /dev/null
@@ -1,61 +0,0 @@
-import uuid
-from qtpy import QtGui, QtCore
-
-from ayon_core.pipeline import discover_legacy_creator_plugins
-
-from . constants import (
- PRODUCT_TYPE_ROLE,
- ITEM_ID_ROLE
-)
-
-
-class CreatorsModel(QtGui.QStandardItemModel):
- def __init__(self, *args, **kwargs):
- super(CreatorsModel, self).__init__(*args, **kwargs)
-
- self._creators_by_id = {}
-
- def reset(self):
- # TODO change to refresh when clearing is not needed
- self.clear()
- self._creators_by_id = {}
-
- items = []
- creators = discover_legacy_creator_plugins()
- for creator in creators:
- if not creator.enabled:
- continue
- item_id = str(uuid.uuid4())
- self._creators_by_id[item_id] = creator
-
- label = creator.label or creator.product_type
- item = QtGui.QStandardItem(label)
- item.setEditable(False)
- item.setData(item_id, ITEM_ID_ROLE)
- item.setData(creator.product_type, PRODUCT_TYPE_ROLE)
- items.append(item)
-
- if not items:
- item = QtGui.QStandardItem("No registered create plugins")
- item.setEnabled(False)
- item.setData(False, QtCore.Qt.ItemIsEnabled)
- items.append(item)
-
- items.sort(key=lambda item: item.text())
- self.invisibleRootItem().appendRows(items)
-
- def get_creator_by_id(self, item_id):
- return self._creators_by_id.get(item_id)
-
- def get_indexes_by_product_type(self, product_type):
- indexes = []
- for row in range(self.rowCount()):
- index = self.index(row, 0)
- item_id = index.data(ITEM_ID_ROLE)
- creator_plugin = self._creators_by_id.get(item_id)
- if creator_plugin and (
- creator_plugin.label.lower() == product_type.lower()
- or creator_plugin.product_type.lower() == product_type.lower()
- ):
- indexes.append(index)
- return indexes
diff --git a/client/ayon_core/tools/creator/widgets.py b/client/ayon_core/tools/creator/widgets.py
deleted file mode 100644
index bbc6848e6c..0000000000
--- a/client/ayon_core/tools/creator/widgets.py
+++ /dev/null
@@ -1,275 +0,0 @@
-import re
-import inspect
-
-from qtpy import QtWidgets, QtCore, QtGui
-
-import qtawesome
-
-from ayon_core.pipeline.create import PRODUCT_NAME_ALLOWED_SYMBOLS
-from ayon_core.tools.utils import ErrorMessageBox
-
-if hasattr(QtGui, "QRegularExpressionValidator"):
- RegularExpressionValidatorClass = QtGui.QRegularExpressionValidator
- RegularExpressionClass = QtCore.QRegularExpression
-else:
- RegularExpressionValidatorClass = QtGui.QRegExpValidator
- RegularExpressionClass = QtCore.QRegExp
-
-
-class CreateErrorMessageBox(ErrorMessageBox):
- def __init__(
- self,
- product_type,
- product_name,
- folder_path,
- exc_msg,
- formatted_traceback,
- parent
- ):
- self._product_type = product_type
- self._product_name = product_name
- self._folder_path = folder_path
- self._exc_msg = exc_msg
- self._formatted_traceback = formatted_traceback
- super(CreateErrorMessageBox, self).__init__("Creation failed", parent)
-
- def _create_top_widget(self, parent_widget):
- label_widget = QtWidgets.QLabel(parent_widget)
- label_widget.setText(
- "Failed to create"
- )
- return label_widget
-
- def _get_report_data(self):
- report_message = (
- "Failed to create Product: \"{product_name}\""
- " Type: \"{product_type}\""
- " in Folder: \"{folder_path}\""
- "\n\nError: {message}"
- ).format(
- product_name=self._product_name,
- product_type=self._product_type,
- folder_path=self._folder_path,
- message=self._exc_msg
- )
- if self._formatted_traceback:
- report_message += "\n\n{}".format(self._formatted_traceback)
- return [report_message]
-
- def _create_content(self, content_layout):
- item_name_template = (
- "{}: {{}}
"
- "{}: {{}}
"
- "{}: {{}}
"
- ).format(
- "Product type",
- "Product name",
- "Folder"
- )
- exc_msg_template = "{}"
-
- line = self._create_line()
- content_layout.addWidget(line)
-
- item_name_widget = QtWidgets.QLabel(self)
- item_name_widget.setText(
- item_name_template.format(
- self._product_type, self._product_name, self._folder_path
- )
- )
- content_layout.addWidget(item_name_widget)
-
- message_label_widget = QtWidgets.QLabel(self)
- message_label_widget.setText(
- exc_msg_template.format(self.convert_text_for_html(self._exc_msg))
- )
- content_layout.addWidget(message_label_widget)
-
- if self._formatted_traceback:
- line_widget = self._create_line()
- tb_widget = self._create_traceback_widget(
- self._formatted_traceback
- )
- content_layout.addWidget(line_widget)
- content_layout.addWidget(tb_widget)
-
-
-class ProductNameValidator(RegularExpressionValidatorClass):
- invalid = QtCore.Signal(set)
- pattern = "^[{}]*$".format(PRODUCT_NAME_ALLOWED_SYMBOLS)
-
- def __init__(self):
- reg = RegularExpressionClass(self.pattern)
- super(ProductNameValidator, self).__init__(reg)
-
- def validate(self, text, pos):
- results = super(ProductNameValidator, self).validate(text, pos)
- if results[0] == RegularExpressionValidatorClass.Invalid:
- self.invalid.emit(self.invalid_chars(text))
- return results
-
- def invalid_chars(self, text):
- invalid = set()
- re_valid = re.compile(self.pattern)
- for char in text:
- if char == " ":
- invalid.add("' '")
- continue
- if not re_valid.match(char):
- invalid.add(char)
- return invalid
-
-
-class VariantLineEdit(QtWidgets.QLineEdit):
- report = QtCore.Signal(str)
- colors = {
- "empty": (QtGui.QColor("#78879b"), ""),
- "exists": (QtGui.QColor("#4E76BB"), "border-color: #4E76BB;"),
- "new": (QtGui.QColor("#7AAB8F"), "border-color: #7AAB8F;"),
- }
-
- def __init__(self, *args, **kwargs):
- super(VariantLineEdit, self).__init__(*args, **kwargs)
-
- validator = ProductNameValidator()
- self.setValidator(validator)
- self.setToolTip("Only alphanumeric characters (A-Z a-z 0-9), "
- "'_' and '.' are allowed.")
-
- self._status_color = self.colors["empty"][0]
-
- anim = QtCore.QPropertyAnimation()
- anim.setTargetObject(self)
- anim.setPropertyName(b"status_color")
- anim.setEasingCurve(QtCore.QEasingCurve.InCubic)
- anim.setDuration(300)
- anim.setStartValue(QtGui.QColor("#C84747")) # `Invalid` status color
- self.animation = anim
-
- validator.invalid.connect(self.on_invalid)
-
- def on_invalid(self, invalid):
- message = "Invalid character: %s" % ", ".join(invalid)
- self.report.emit(message)
- self.animation.stop()
- self.animation.start()
-
- def as_empty(self):
- self._set_border("empty")
- self.report.emit("Empty product name ..")
-
- def as_exists(self):
- self._set_border("exists")
- self.report.emit("Existing product, appending next version.")
-
- def as_new(self):
- self._set_border("new")
- self.report.emit("New product, creating first version.")
-
- def _set_border(self, status):
- qcolor, style = self.colors[status]
- self.animation.setEndValue(qcolor)
- self.setStyleSheet(style)
-
- def _get_status_color(self):
- return self._status_color
-
- def _set_status_color(self, color):
- self._status_color = color
- self.setStyleSheet("border-color: %s;" % color.name())
-
- status_color = QtCore.Property(
- QtGui.QColor, _get_status_color, _set_status_color
- )
-
-
-class ProductTypeDescriptionWidget(QtWidgets.QWidget):
- """A product type description widget.
-
- Shows a product type icon, name and a help description.
- Used in creator header.
-
- _______________________
- | ____ |
- | |icon| PRODUCT TYPE |
- | |____| help |
- |_______________________|
-
- """
-
- SIZE = 35
-
- def __init__(self, parent=None):
- super(ProductTypeDescriptionWidget, self).__init__(parent=parent)
-
- icon_label = QtWidgets.QLabel(self)
- icon_label.setSizePolicy(
- QtWidgets.QSizePolicy.Maximum,
- QtWidgets.QSizePolicy.Maximum
- )
-
- # Add 4 pixel padding to avoid icon being cut off
- icon_label.setFixedWidth(self.SIZE + 4)
- icon_label.setFixedHeight(self.SIZE + 4)
-
- label_layout = QtWidgets.QVBoxLayout()
- label_layout.setSpacing(0)
-
- product_type_label = QtWidgets.QLabel(self)
- product_type_label.setObjectName("CreatorProductTypeLabel")
- product_type_label.setAlignment(
- QtCore.Qt.AlignBottom | QtCore.Qt.AlignLeft
- )
-
- help_label = QtWidgets.QLabel(self)
- help_label.setAlignment(QtCore.Qt.AlignTop | QtCore.Qt.AlignLeft)
-
- label_layout.addWidget(product_type_label)
- label_layout.addWidget(help_label)
-
- layout = QtWidgets.QHBoxLayout(self)
- layout.setContentsMargins(0, 0, 0, 0)
- layout.setSpacing(5)
- layout.addWidget(icon_label)
- layout.addLayout(label_layout)
-
- self._help_label = help_label
- self._product_type_label = product_type_label
- self._icon_label = icon_label
-
- def set_item(self, creator_plugin):
- """Update elements to display information of a product type item.
-
- Args:
- creator_plugin (dict): A product type item as registered with
- name, help and icon.
-
- Returns:
- None
-
- """
- if not creator_plugin:
- self._icon_label.setPixmap(None)
- self._product_type_label.setText("")
- self._help_label.setText("")
- return
-
- # Support a font-awesome icon
- icon_name = getattr(creator_plugin, "icon", None) or "info-circle"
- try:
- icon = qtawesome.icon("fa.{}".format(icon_name), color="white")
- pixmap = icon.pixmap(self.SIZE, self.SIZE)
- except Exception:
- print("BUG: Couldn't load icon \"fa.{}\"".format(str(icon_name)))
- # Create transparent pixmap
- pixmap = QtGui.QPixmap()
- pixmap.fill(QtCore.Qt.transparent)
- pixmap = pixmap.scaled(self.SIZE, self.SIZE)
-
- # Parse a clean line from the Creator's docstring
- docstring = inspect.getdoc(creator_plugin)
- creator_help = docstring.splitlines()[0] if docstring else ""
-
- self._icon_label.setPixmap(pixmap)
- self._product_type_label.setText(creator_plugin.product_type)
- self._help_label.setText(creator_help)
diff --git a/client/ayon_core/tools/creator/window.py b/client/ayon_core/tools/creator/window.py
deleted file mode 100644
index 5d1c0a272a..0000000000
--- a/client/ayon_core/tools/creator/window.py
+++ /dev/null
@@ -1,508 +0,0 @@
-import sys
-import traceback
-import re
-
-import ayon_api
-from qtpy import QtWidgets, QtCore
-
-from ayon_core import style
-from ayon_core.settings import get_current_project_settings
-from ayon_core.tools.utils.lib import qt_app_context
-from ayon_core.pipeline import (
- get_current_project_name,
- get_current_folder_path,
- get_current_task_name,
-)
-from ayon_core.pipeline.create import (
- PRODUCT_NAME_ALLOWED_SYMBOLS,
- legacy_create,
- CreatorError,
-)
-
-from .model import CreatorsModel
-from .widgets import (
- CreateErrorMessageBox,
- VariantLineEdit,
- ProductTypeDescriptionWidget
-)
-from .constants import (
- ITEM_ID_ROLE,
- SEPARATOR,
- SEPARATORS
-)
-
-module = sys.modules[__name__]
-module.window = None
-
-
-class CreatorWindow(QtWidgets.QDialog):
- def __init__(self, parent=None):
- super(CreatorWindow, self).__init__(parent)
- self.setWindowTitle("Instance Creator")
- self.setFocusPolicy(QtCore.Qt.StrongFocus)
- if not parent:
- self.setWindowFlags(
- self.windowFlags() | QtCore.Qt.WindowStaysOnTopHint
- )
-
- creator_info = ProductTypeDescriptionWidget(self)
-
- creators_model = CreatorsModel()
-
- creators_proxy = QtCore.QSortFilterProxyModel()
- creators_proxy.setSourceModel(creators_model)
-
- creators_view = QtWidgets.QListView(self)
- creators_view.setObjectName("CreatorsView")
- creators_view.setModel(creators_proxy)
-
- folder_path_input = QtWidgets.QLineEdit(self)
- variant_input = VariantLineEdit(self)
- product_name_input = QtWidgets.QLineEdit(self)
- product_name_input.setEnabled(False)
-
- variants_btn = QtWidgets.QPushButton()
- variants_btn.setFixedWidth(18)
- variants_menu = QtWidgets.QMenu(variants_btn)
- variants_btn.setMenu(variants_menu)
-
- name_layout = QtWidgets.QHBoxLayout()
- name_layout.addWidget(variant_input)
- name_layout.addWidget(variants_btn)
- name_layout.setSpacing(3)
- name_layout.setContentsMargins(0, 0, 0, 0)
-
- body_layout = QtWidgets.QVBoxLayout()
- body_layout.setContentsMargins(0, 0, 0, 0)
-
- body_layout.addWidget(creator_info, 0)
- body_layout.addWidget(QtWidgets.QLabel("Product type", self), 0)
- body_layout.addWidget(creators_view, 1)
- body_layout.addWidget(QtWidgets.QLabel("Folder path", self), 0)
- body_layout.addWidget(folder_path_input, 0)
- body_layout.addWidget(QtWidgets.QLabel("Product name", self), 0)
- body_layout.addLayout(name_layout, 0)
- body_layout.addWidget(product_name_input, 0)
-
- useselection_chk = QtWidgets.QCheckBox("Use selection", self)
- useselection_chk.setCheckState(QtCore.Qt.Checked)
-
- create_btn = QtWidgets.QPushButton("Create", self)
- # Need to store error_msg to prevent garbage collection
- msg_label = QtWidgets.QLabel(self)
-
- footer_layout = QtWidgets.QVBoxLayout()
- footer_layout.addWidget(create_btn, 0)
- footer_layout.addWidget(msg_label, 0)
- footer_layout.setContentsMargins(0, 0, 0, 0)
-
- layout = QtWidgets.QVBoxLayout(self)
- layout.addLayout(body_layout, 1)
- layout.addWidget(useselection_chk, 0, QtCore.Qt.AlignLeft)
- layout.addLayout(footer_layout, 0)
-
- msg_timer = QtCore.QTimer()
- msg_timer.setSingleShot(True)
- msg_timer.setInterval(5000)
-
- validation_timer = QtCore.QTimer()
- validation_timer.setSingleShot(True)
- validation_timer.setInterval(300)
-
- msg_timer.timeout.connect(self._on_msg_timer)
- validation_timer.timeout.connect(self._on_validation_timer)
-
- create_btn.clicked.connect(self._on_create)
- variant_input.returnPressed.connect(self._on_create)
- variant_input.textChanged.connect(self._on_data_changed)
- variant_input.report.connect(self.echo)
- folder_path_input.textChanged.connect(self._on_data_changed)
- creators_view.selectionModel().currentChanged.connect(
- self._on_selection_changed
- )
-
- # Store valid states and
- self._is_valid = False
- create_btn.setEnabled(self._is_valid)
-
- self._first_show = True
-
- # Message dialog when something goes wrong during creation
- self._message_dialog = None
-
- self._creator_info = creator_info
- self._create_btn = create_btn
- self._useselection_chk = useselection_chk
- self._variant_input = variant_input
- self._product_name_input = product_name_input
- self._folder_path_input = folder_path_input
-
- self._creators_model = creators_model
- self._creators_proxy = creators_proxy
- self._creators_view = creators_view
-
- self._variants_btn = variants_btn
- self._variants_menu = variants_menu
-
- self._msg_label = msg_label
-
- self._validation_timer = validation_timer
- self._msg_timer = msg_timer
-
- # Defaults
- self.resize(300, 500)
- variant_input.setFocus()
-
- def _set_valid_state(self, valid):
- if self._is_valid == valid:
- return
- self._is_valid = valid
- self._create_btn.setEnabled(valid)
-
- def _build_menu(self, default_names=None):
- """Create optional predefined variants.
-
- Args:
- default_names(list): all predefined names
-
- Returns:
- None
- """
- if not default_names:
- default_names = []
-
- menu = self._variants_menu
- button = self._variants_btn
-
- # Get and destroy the action group
- group = button.findChild(QtWidgets.QActionGroup)
- if group:
- group.deleteLater()
-
- state = any(default_names)
- button.setEnabled(state)
- if state is False:
- return
-
- # Build new action group
- group = QtWidgets.QActionGroup(button)
- for name in default_names:
- if name in SEPARATORS:
- menu.addSeparator()
- continue
- action = group.addAction(name)
- menu.addAction(action)
-
- group.triggered.connect(self._on_action_clicked)
-
- def _on_action_clicked(self, action):
- self._variant_input.setText(action.text())
-
- def _on_data_changed(self, *args):
- # Set invalid state until it's reconfirmed to be valid by the
- # scheduled callback so any form of creation is held back until
- # valid again
- self._set_valid_state(False)
-
- self._validation_timer.start()
-
- def _on_validation_timer(self):
- index = self._creators_view.currentIndex()
- item_id = index.data(ITEM_ID_ROLE)
- creator_plugin = self._creators_model.get_creator_by_id(item_id)
- user_input_text = self._variant_input.text()
- folder_path = self._folder_path_input.text()
-
- # Early exit if no folder path
- if not folder_path:
- self._build_menu()
- self.echo("Folder is required ..")
- self._set_valid_state(False)
- return
-
- project_name = get_current_project_name()
- folder_entity = None
- if creator_plugin:
- # Get the folder from the database which match with the name
- folder_entity = ayon_api.get_folder_by_path(
- project_name, folder_path, fields={"id"}
- )
-
- # Get plugin
- if not folder_entity or not creator_plugin:
- self._build_menu()
-
- if not creator_plugin:
- self.echo("No registered product types ..")
- else:
- self.echo("Folder '{}' not found ..".format(folder_path))
- self._set_valid_state(False)
- return
-
- folder_id = folder_entity["id"]
-
- task_name = get_current_task_name()
- task_entity = ayon_api.get_task_by_name(
- project_name, folder_id, task_name
- )
-
- # Calculate product name with Creator plugin
- product_name = creator_plugin.get_product_name(
- project_name, folder_entity, task_entity, user_input_text
- )
- # Force replacement of prohibited symbols
- # QUESTION should Creator care about this and here should be only
- # validated with schema regex?
-
- # Allow curly brackets in product name for dynamic keys
- curly_left = "__cbl__"
- curly_right = "__cbr__"
- tmp_product_name = (
- product_name
- .replace("{", curly_left)
- .replace("}", curly_right)
- )
- # Replace prohibited symbols
- tmp_product_name = re.sub(
- "[^{}]+".format(PRODUCT_NAME_ALLOWED_SYMBOLS),
- "",
- tmp_product_name
- )
- product_name = (
- tmp_product_name
- .replace(curly_left, "{")
- .replace(curly_right, "}")
- )
- self._product_name_input.setText(product_name)
-
- # Get all products of the current folder
- product_entities = ayon_api.get_products(
- project_name, folder_ids={folder_id}, fields={"name"}
- )
- existing_product_names = {
- product_entity["name"]
- for product_entity in product_entities
- }
- existing_product_names_low = set(
- _name.lower()
- for _name in existing_product_names
- )
-
- # Defaults to dropdown
- defaults = []
- # Check if Creator plugin has set defaults
- if (
- creator_plugin.defaults
- and isinstance(creator_plugin.defaults, (list, tuple, set))
- ):
- defaults = list(creator_plugin.defaults)
-
- # Replace
- compare_regex = re.compile(re.sub(
- user_input_text, "(.+)", product_name, flags=re.IGNORECASE
- ))
- variant_hints = set()
- if user_input_text:
- for _name in existing_product_names:
- _result = compare_regex.search(_name)
- if _result:
- variant_hints |= set(_result.groups())
-
- if variant_hints:
- if defaults:
- defaults.append(SEPARATOR)
- defaults.extend(variant_hints)
- self._build_menu(defaults)
-
- # Indicate product existence
- if not user_input_text:
- self._variant_input.as_empty()
- elif product_name.lower() in existing_product_names_low:
- # validate existence of product name with lowered text
- # - "renderMain" vs. "rensermain" mean same path item for
- # windows
- self._variant_input.as_exists()
- else:
- self._variant_input.as_new()
-
- # Update the valid state
- valid = product_name.strip() != ""
-
- self._set_valid_state(valid)
-
- def _on_selection_changed(self, old_idx, new_idx):
- index = self._creators_view.currentIndex()
- item_id = index.data(ITEM_ID_ROLE)
-
- creator_plugin = self._creators_model.get_creator_by_id(item_id)
-
- self._creator_info.set_item(creator_plugin)
-
- if creator_plugin is None:
- return
-
- default = None
- if hasattr(creator_plugin, "get_default_variant"):
- default = creator_plugin.get_default_variant()
-
- if not default:
- if (
- creator_plugin.defaults
- and isinstance(creator_plugin.defaults, list)
- ):
- default = creator_plugin.defaults[0]
- else:
- default = "Default"
-
- self._variant_input.setText(default)
-
- self._on_data_changed()
-
- def keyPressEvent(self, event):
- """Custom keyPressEvent.
-
- Override keyPressEvent to do nothing so that Maya's panels won't
- take focus when pressing "SHIFT" whilst mouse is over viewport or
- outliner. This way users don't accidentally perform Maya commands
- whilst trying to name an instance.
-
- """
- pass
-
- def showEvent(self, event):
- super(CreatorWindow, self).showEvent(event)
- if self._first_show:
- self._first_show = False
- self.setStyleSheet(style.load_stylesheet())
-
- def refresh(self):
- self._folder_path_input.setText(get_current_folder_path())
-
- self._creators_model.reset()
-
- product_types_smart_select = (
- get_current_project_settings()
- ["core"]
- ["tools"]
- ["creator"]
- ["product_types_smart_select"]
- )
- current_index = None
- product_type = None
- task_name = get_current_task_name() or None
- lowered_task_name = task_name.lower()
- if task_name:
- for smart_item in product_types_smart_select:
- _low_task_names = {
- name.lower() for name in smart_item["task_names"]
- }
- for _task_name in _low_task_names:
- if _task_name in lowered_task_name:
- product_type = smart_item["name"]
- break
- if product_type:
- break
-
- if product_type:
- indexes = self._creators_model.get_indexes_by_product_type(
- product_type
- )
- if indexes:
- index = indexes[0]
- current_index = self._creators_proxy.mapFromSource(index)
-
- if current_index is None or not current_index.isValid():
- current_index = self._creators_proxy.index(0, 0)
-
- self._creators_view.setCurrentIndex(current_index)
-
- def _on_create(self):
- # Do not allow creation in an invalid state
- if not self._is_valid:
- return
-
- index = self._creators_view.currentIndex()
- item_id = index.data(ITEM_ID_ROLE)
- creator_plugin = self._creators_model.get_creator_by_id(item_id)
- if creator_plugin is None:
- return
-
- product_name = self._product_name_input.text()
- folder_path = self._folder_path_input.text()
- use_selection = self._useselection_chk.isChecked()
-
- variant = self._variant_input.text()
-
- error_info = None
- try:
- legacy_create(
- creator_plugin,
- product_name,
- folder_path,
- options={"useSelection": use_selection},
- data={"variant": variant}
- )
-
- except CreatorError as exc:
- self.echo("Creator error: {}".format(str(exc)))
- error_info = (str(exc), None)
-
- except Exception as exc:
- self.echo("Program error: %s" % str(exc))
-
- exc_type, exc_value, exc_traceback = sys.exc_info()
- formatted_traceback = "".join(traceback.format_exception(
- exc_type, exc_value, exc_traceback
- ))
- error_info = (str(exc), formatted_traceback)
-
- if error_info:
- box = CreateErrorMessageBox(
- creator_plugin.product_type,
- product_name,
- folder_path,
- *error_info,
- parent=self
- )
- box.show()
- # Store dialog so is not garbage collected before is shown
- self._message_dialog = box
-
- else:
- self.echo("Created %s .." % product_name)
-
- def _on_msg_timer(self):
- self._msg_label.setText("")
-
- def echo(self, message):
- self._msg_label.setText(str(message))
- self._msg_timer.start()
-
-
-def show(parent=None):
- """Display product creator GUI
-
- Arguments:
- debug (bool, optional): Run loader in debug-mode,
- defaults to False
- parent (QtCore.QObject, optional): When provided parent the interface
- to this QObject.
-
- """
-
- try:
- module.window.close()
- del module.window
- except (AttributeError, RuntimeError):
- pass
-
- with qt_app_context():
- window = CreatorWindow(parent)
- window.refresh()
- window.show()
-
- module.window = window
-
- # Pull window to the front.
- module.window.raise_()
- module.window.activateWindow()
diff --git a/client/ayon_core/tools/publisher/abstract.py b/client/ayon_core/tools/publisher/abstract.py
index 6d0027d35d..14da15793d 100644
--- a/client/ayon_core/tools/publisher/abstract.py
+++ b/client/ayon_core/tools/publisher/abstract.py
@@ -13,7 +13,7 @@ from typing import (
)
from ayon_core.lib import AbstractAttrDef
-from ayon_core.host import HostBase
+from ayon_core.host import AbstractHost
from ayon_core.pipeline.create import (
CreateContext,
ConvertorItem,
@@ -176,7 +176,7 @@ class AbstractPublisherBackend(AbstractPublisherCommon):
pass
@abstractmethod
- def get_host(self) -> HostBase:
+ def get_host(self) -> AbstractHost:
pass
@abstractmethod
diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py
index 75ed2c73fe..5098826b8b 100644
--- a/client/ayon_core/tools/publisher/models/create.py
+++ b/client/ayon_core/tools/publisher/models/create.py
@@ -219,6 +219,8 @@ class InstanceItem:
is_active: bool,
is_mandatory: bool,
has_promised_context: bool,
+ parent_instance_id: Optional[str],
+ parent_flags: int,
):
self._instance_id: str = instance_id
self._creator_identifier: str = creator_identifier
@@ -232,6 +234,8 @@ class InstanceItem:
self._is_active: bool = is_active
self._is_mandatory: bool = is_mandatory
self._has_promised_context: bool = has_promised_context
+ self._parent_instance_id: Optional[str] = parent_instance_id
+ self._parent_flags: int = parent_flags
@property
def id(self):
@@ -261,6 +265,14 @@ class InstanceItem:
def has_promised_context(self):
return self._has_promised_context
+ @property
+ def parent_instance_id(self):
+ return self._parent_instance_id
+
+ @property
+ def parent_flags(self) -> int:
+ return self._parent_flags
+
def get_variant(self):
return self._variant
@@ -312,6 +324,8 @@ class InstanceItem:
instance["active"],
instance.is_mandatory,
instance.has_promised_context,
+ instance.parent_instance_id,
+ instance.parent_flags,
)
@@ -486,6 +500,9 @@ class CreateModel:
self._create_context.add_instance_requirement_change_callback(
self._cc_instance_requirement_changed
)
+ self._create_context.add_instance_parent_change_callback(
+ self._cc_instance_parent_changed
+ )
self._create_context.reset_finalization()
@@ -566,15 +583,21 @@ class CreateModel:
def set_instances_active_state(
self, active_state_by_id: Dict[str, bool]
):
+ changed_ids = set()
with self._create_context.bulk_value_changes(CREATE_EVENT_SOURCE):
for instance_id, active in active_state_by_id.items():
instance = self._create_context.get_instance_by_id(instance_id)
- instance["active"] = active
+ if instance["active"] is not active:
+ instance["active"] = active
+ changed_ids.add(instance_id)
+
+ if not changed_ids:
+ return
self._emit_event(
"create.model.instances.context.changed",
{
- "instance_ids": set(active_state_by_id.keys())
+ "instance_ids": changed_ids
}
)
@@ -1191,6 +1214,16 @@ class CreateModel:
{"instance_ids": instance_ids},
)
+ def _cc_instance_parent_changed(self, event):
+ instance_ids = {
+ instance.id
+ for instance in event.data["instances"]
+ }
+ self._emit_event(
+ "create.model.instance.parent.changed",
+ {"instance_ids": instance_ids},
+ )
+
def _get_allowed_creators_pattern(self) -> Union[Pattern, None]:
"""Provide regex pattern for configured creator labels in this context
diff --git a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py
index 8a4eddf058..84786a671e 100644
--- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py
+++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py
@@ -19,18 +19,21 @@ Only one item can be selected at a time.
└──────────────────────┘
```
"""
+from __future__ import annotations
import re
import collections
-from typing import Dict
+from typing import Optional
from qtpy import QtWidgets, QtCore
-from ayon_core.tools.utils import NiceCheckbox
+from ayon_core.pipeline.create import (
+ InstanceContextInfo,
+ ParentFlags,
+)
-from ayon_core.tools.utils import BaseClickableFrame
+from ayon_core.tools.utils import BaseClickableFrame, NiceCheckbox
from ayon_core.tools.utils.lib import html_escape
-
from ayon_core.tools.publisher.abstract import AbstractPublisherFrontend
from ayon_core.tools.publisher.constants import (
CONTEXT_ID,
@@ -38,7 +41,9 @@ from ayon_core.tools.publisher.constants import (
CONTEXT_GROUP,
CONVERTOR_ITEM_GROUP,
)
-
+from ayon_core.tools.publisher.models.create import (
+ InstanceItem,
+)
from .widgets import (
AbstractInstanceView,
ContextWarningLabel,
@@ -82,7 +87,6 @@ class BaseGroupWidget(QtWidgets.QWidget):
self._group = group_name
self._widgets_by_id = {}
- self._ordered_item_ids = []
self._label_widget = label_widget
self._content_layout = layout
@@ -97,48 +101,25 @@ class BaseGroupWidget(QtWidgets.QWidget):
return self._group
- def get_widget_by_item_id(self, item_id):
- """Get instance widget by its id."""
+ def set_widgets(
+ self,
+ widgets_by_id: dict[str, QtWidgets.QWidget],
+ ordered_ids: list[str],
+ ) -> None:
+ self._remove_all_except(set(self._widgets_by_id))
+ idx = 1
+ for item_id in ordered_ids:
+ widget = widgets_by_id[item_id]
+ self._content_layout.insertWidget(idx, widget)
+ self._widgets_by_id[item_id] = widget
+ idx += 1
- return self._widgets_by_id.get(item_id)
-
- def get_selected_item_ids(self):
- """Selected instance ids.
-
- Returns:
- Set[str]: Instance ids that are selected.
- """
-
- return {
- instance_id
- for instance_id, widget in self._widgets_by_id.items()
- if widget.is_selected
- }
-
- def get_selected_widgets(self):
- """Access to widgets marked as selected.
-
- Returns:
- List[InstanceCardWidget]: Instance widgets that are selected.
- """
-
- return [
- widget
- for instance_id, widget in self._widgets_by_id.items()
- if widget.is_selected
- ]
-
- def get_ordered_widgets(self):
- """Get instance ids in order as are shown in ui.
-
- Returns:
- List[str]: Instance ids.
- """
-
- return [
- self._widgets_by_id[instance_id]
- for instance_id in self._ordered_item_ids
- ]
+ def take_widgets(self, widget_ids: set[str]):
+ for widget_id in widget_ids:
+ widget = self._widgets_by_id.pop(widget_id)
+ index = self._content_layout.indexOf(widget)
+ if index >= 0:
+ self._content_layout.takeAt(index)
def _remove_all_except(self, item_ids):
item_ids = set(item_ids)
@@ -155,131 +136,6 @@ class BaseGroupWidget(QtWidgets.QWidget):
self._content_layout.removeWidget(widget)
widget.deleteLater()
- def _update_ordered_item_ids(self):
- ordered_item_ids = []
- for idx in range(self._content_layout.count()):
- if idx > 0:
- item = self._content_layout.itemAt(idx)
- widget = item.widget()
- if widget is not None:
- ordered_item_ids.append(widget.id)
-
- self._ordered_item_ids = ordered_item_ids
-
- def _on_widget_selection(self, instance_id, group_id, selection_type):
- self.selected.emit(instance_id, group_id, selection_type)
-
- def set_active_toggle_enabled(self, enabled):
- for widget in self._widgets_by_id.values():
- if isinstance(widget, InstanceCardWidget):
- widget.set_active_toggle_enabled(enabled)
-
-
-class ConvertorItemsGroupWidget(BaseGroupWidget):
- def update_items(self, items_by_id):
- items_by_label = collections.defaultdict(list)
- for item in items_by_id.values():
- items_by_label[item.label].append(item)
-
- # Remove instance widgets that are not in passed instances
- self._remove_all_except(items_by_id.keys())
-
- # Sort instances by product name
- sorted_labels = list(sorted(items_by_label.keys()))
-
- # Add new instances to widget
- widget_idx = 1
- for label in sorted_labels:
- for item in items_by_label[label]:
- if item.id in self._widgets_by_id:
- widget = self._widgets_by_id[item.id]
- widget.update_item(item)
- else:
- widget = ConvertorItemCardWidget(item, self)
- widget.selected.connect(self._on_widget_selection)
- widget.double_clicked.connect(self.double_clicked)
- self._widgets_by_id[item.id] = widget
- self._content_layout.insertWidget(widget_idx, widget)
- widget_idx += 1
-
- self._update_ordered_item_ids()
-
-
-class InstanceGroupWidget(BaseGroupWidget):
- """Widget wrapping instances under group."""
-
- active_changed = QtCore.Signal(str, str, bool)
-
- def __init__(self, group_icons, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- self._group_icons = group_icons
-
- def update_icons(self, group_icons):
- self._group_icons = group_icons
-
- def update_instance_values(
- self, context_info_by_id, instance_items_by_id, instance_ids
- ):
- """Trigger update on instance widgets."""
-
- for instance_id, widget in self._widgets_by_id.items():
- if instance_ids is not None and instance_id not in instance_ids:
- continue
- widget.update_instance(
- instance_items_by_id[instance_id],
- context_info_by_id[instance_id]
- )
-
- def update_instances(self, instances, context_info_by_id):
- """Update instances for the group.
-
- Args:
- instances (list[InstanceItem]): List of instances in
- CreateContext.
- context_info_by_id (Dict[str, InstanceContextInfo]): Instance
- context info by instance id.
-
- """
- # Store instances by id and by product name
- instances_by_id = {}
- instances_by_product_name = collections.defaultdict(list)
- for instance in instances:
- instances_by_id[instance.id] = instance
- product_name = instance.product_name
- instances_by_product_name[product_name].append(instance)
-
- # Remove instance widgets that are not in passed instances
- self._remove_all_except(instances_by_id.keys())
-
- # Sort instances by product name
- sorted_product_names = list(sorted(instances_by_product_name.keys()))
-
- # Add new instances to widget
- widget_idx = 1
- for product_names in sorted_product_names:
- for instance in instances_by_product_name[product_names]:
- context_info = context_info_by_id[instance.id]
- if instance.id in self._widgets_by_id:
- widget = self._widgets_by_id[instance.id]
- widget.update_instance(instance, context_info)
- else:
- group_icon = self._group_icons[instance.creator_identifier]
- widget = InstanceCardWidget(
- instance, context_info, group_icon, self
- )
- widget.selected.connect(self._on_widget_selection)
- widget.active_changed.connect(self._on_active_changed)
- widget.double_clicked.connect(self.double_clicked)
- self._widgets_by_id[instance.id] = widget
- self._content_layout.insertWidget(widget_idx, widget)
- widget_idx += 1
-
- self._update_ordered_item_ids()
-
- def _on_active_changed(self, instance_id, value):
- self.active_changed.emit(self.group_name, instance_id, value)
-
class CardWidget(BaseClickableFrame):
"""Clickable card used as bigger button."""
@@ -400,20 +256,34 @@ class ConvertorItemCardWidget(CardWidget):
self._icon_widget = icon_widget
self._label_widget = label_widget
+ def update_item(self, item):
+ self._id = item.id
+ self.identifier = item.identifier
+
class InstanceCardWidget(CardWidget):
"""Card widget representing instance."""
active_changed = QtCore.Signal(str, bool)
- def __init__(self, instance, context_info, group_icon, parent):
+ def __init__(
+ self,
+ instance,
+ context_info,
+ is_parent_active: bool,
+ group_icon,
+ parent: BaseGroupWidget,
+ ):
super().__init__(parent)
+ self.instance = instance
+ self._is_active = instance.is_active
+
self._id = instance.id
self._group_identifier = instance.group_label
self._group_icon = group_icon
-
- self.instance = instance
+ self._is_parent_active = is_parent_active
+ self._toggle_is_enabled = True
self._last_product_name = None
self._last_variant = None
@@ -439,10 +309,6 @@ class InstanceCardWidget(CardWidget):
expand_btn.setMaximumWidth(14)
expand_btn.setEnabled(False)
- detail_widget = QtWidgets.QWidget(self)
- detail_widget.setVisible(False)
- self.detail_widget = detail_widget
-
top_layout = QtWidgets.QHBoxLayout()
top_layout.addLayout(icon_layout, 0)
top_layout.addWidget(label_widget, 1)
@@ -450,6 +316,9 @@ class InstanceCardWidget(CardWidget):
top_layout.addWidget(active_checkbox, 0)
top_layout.addWidget(expand_btn, 0)
+ detail_widget = QtWidgets.QWidget(self)
+ detail_widget.setVisible(False)
+
layout = QtWidgets.QHBoxLayout(self)
layout.setContentsMargins(0, 2, 10, 2)
layout.addLayout(top_layout)
@@ -467,28 +336,47 @@ class InstanceCardWidget(CardWidget):
self._active_checkbox = active_checkbox
self._expand_btn = expand_btn
- self._update_instance_values(context_info)
+ self._detail_widget = detail_widget
- def set_active_toggle_enabled(self, enabled):
- self._active_checkbox.setEnabled(enabled)
+ self._update_instance_values(context_info, is_parent_active)
- @property
- def is_active(self):
+ def set_active_toggle_enabled(self, enabled: bool) -> None:
+ if self._toggle_is_enabled is enabled:
+ return
+ self._toggle_is_enabled = enabled
+ self._update_checkbox_state()
+
+ def is_active(self) -> bool:
return self._active_checkbox.isChecked()
- def _set_active(self, new_value):
- """Set instance as active."""
- checkbox_value = self._active_checkbox.isChecked()
- if checkbox_value != new_value:
- self._active_checkbox.setChecked(new_value)
+ def set_active(self, active: Optional[bool]) -> None:
+ if not self.is_checkbox_enabled():
+ return
+ if active is None:
+ active = not self._is_active
+ self._set_checked(active)
- def _set_is_mandatory(self, is_mandatory: bool) -> None:
- self._active_checkbox.setVisible(not is_mandatory)
+ def is_parent_active(self) -> bool:
+ return self._is_parent_active
- def update_instance(self, instance, context_info):
+ def set_parent_active(self, is_active: bool) -> None:
+ if self._is_parent_active is is_active:
+ return
+ self._is_parent_active = is_active
+ self._update_checkbox_state()
+
+ def is_checkbox_enabled(self) -> bool:
+ """Checkbox can be changed by user."""
+ return (
+ self._used_parent_active()
+ and not self.instance.is_mandatory
+ )
+
+ def update_instance(self, instance, context_info, is_parent_active):
"""Update instance object and update UI."""
self.instance = instance
- self._update_instance_values(context_info)
+ self._is_active = instance.is_active
+ self._update_instance_values(context_info, is_parent_active)
def _validate_context(self, context_info):
valid = context_info.is_valid
@@ -499,6 +387,7 @@ class InstanceCardWidget(CardWidget):
variant = self.instance.variant
product_name = self.instance.product_name
label = self.instance.label
+
if (
variant == self._last_variant
and product_name == self._last_product_name
@@ -524,24 +413,53 @@ class InstanceCardWidget(CardWidget):
QtCore.Qt.NoTextInteraction
)
- def _update_instance_values(self, context_info):
+ def _update_instance_values(self, context_info, is_parent_active):
"""Update instance data"""
+ self._is_parent_active = is_parent_active
self._update_product_name()
- self._set_active(self.instance.is_active)
- self._set_is_mandatory(self.instance.is_mandatory)
+ self._update_checkbox_state()
self._validate_context(context_info)
+ def _update_checkbox_state(self):
+ parent_is_enabled = self._used_parent_active()
+ self._label_widget.setEnabled(parent_is_enabled)
+ self._active_checkbox.setEnabled(
+ self._toggle_is_enabled
+ and not self.instance.is_mandatory
+ and parent_is_enabled
+ )
+ # Hide checkbox for mandatory instances
+ self._active_checkbox.setVisible(not self.instance.is_mandatory)
+
+ # Visually disable instance if parent is disabled
+ checked = parent_is_enabled and self._is_active
+ self._set_checked(checked)
+
+ def _set_checked(self, checked: bool) -> None:
+ if checked is not self._active_checkbox.isChecked():
+ self._active_checkbox.blockSignals(True)
+ self._active_checkbox.setChecked(checked)
+ self._active_checkbox.blockSignals(False)
+
+ def _used_parent_active(self) -> bool:
+ parent_enabled = True
+ if self.instance.parent_flags & ParentFlags.share_active:
+ parent_enabled = self._is_parent_active
+ return parent_enabled
+
def _set_expanded(self, expanded=None):
if expanded is None:
- expanded = not self.detail_widget.isVisible()
- self.detail_widget.setVisible(expanded)
+ expanded = not self._detail_widget.isVisible()
+ self._detail_widget.setVisible(expanded)
def _on_active_change(self):
- new_value = self._active_checkbox.isChecked()
- old_value = self.instance.is_active
- if new_value == old_value:
+ if not self.is_checkbox_enabled():
return
-
+ new_value = self._active_checkbox.isChecked()
+ old_value = self._is_active
+ if new_value is old_value:
+ return
+ self._is_active = new_value
self.active_changed.emit(self._id, new_value)
def _on_expend_clicked(self):
@@ -595,11 +513,22 @@ class InstanceCardView(AbstractInstanceView):
self._content_layout = content_layout
self._content_widget = content_widget
- self._context_widget = None
- self._convertor_items_group = None
- self._active_toggle_enabled = True
- self._widgets_by_group: Dict[str, InstanceGroupWidget] = {}
+ self._active_toggle_enabled: bool = True
+ self._convertors_group: Optional[BaseGroupWidget] = None
+ self._convertor_widgets_by_id: dict[str, ConvertorItemCardWidget] = {}
+ self._convertor_ids: list[str] = []
+
+ self._group_name_by_instance_id: dict[str, str] = {}
+ self._instance_ids_by_group_name: dict[str, list[str]] = (
+ collections.defaultdict(list)
+ )
self._ordered_groups = []
+ self._context_widget: Optional[ContextCardWidget] = None
+ self._widgets_by_id: dict[str, InstanceCardWidget] = {}
+ self._widgets_by_group: dict[str, BaseGroupWidget] = {}
+
+ self._parent_id_by_id = {}
+ self._instance_ids_by_parent_id = collections.defaultdict(set)
self._explicitly_selected_instance_ids = []
self._explicitly_selected_groups = []
@@ -622,42 +551,104 @@ class InstanceCardView(AbstractInstanceView):
result.setWidth(width)
return result
- def _toggle_instances(self, value):
- if not self._active_toggle_enabled:
- return
+ def get_current_instance_count(self) -> int:
+ """How many instances are currently in the view."""
+ return len(self._widgets_by_id)
- widgets = self._get_selected_widgets()
- active_state_by_id = {}
- for widget in widgets:
- if not isinstance(widget, InstanceCardWidget):
+ def _get_affected_ids(self, instance_ids: set[str]) -> set[str]:
+ affected_ids = set()
+ affected_queue = collections.deque()
+ affected_queue.extend(instance_ids)
+ while affected_queue:
+ instance_id = affected_queue.popleft()
+ if instance_id in affected_ids:
continue
+ affected_ids.add(instance_id)
+ parent_id = instance_id
+ while True:
+ parent_id = self._parent_id_by_id[parent_id]
+ if parent_id is None:
+ break
+ affected_ids.add(parent_id)
- instance_id = widget.id
- is_active = widget.is_active
- if value == -1:
- active_state_by_id[instance_id] = not is_active
- continue
+ child_ids = set(self._instance_ids_by_parent_id[instance_id])
+ affected_queue.extend(child_ids - affected_ids)
+ return affected_ids
- _value = bool(value)
- if is_active is not _value:
- active_state_by_id[instance_id] = _value
+ def _toggle_instances(
+ self,
+ new_value: Optional[bool],
+ active_id: Optional[str] = None,
+ ) -> None:
+ instance_ids = {
+ widget.id
+ for widget in self._get_selected_instance_widgets()
+ if widget.is_selected
+ }
+ active_by_id = {}
+ if active_id and active_id not in instance_ids:
+ instance_ids = {active_id}
- if not active_state_by_id:
- return
+ ids_to_toggle = set(instance_ids)
- self._controller.set_instances_active_state(active_state_by_id)
+ affected_ids = self._get_affected_ids(instance_ids)
+
+ _queue = collections.deque()
+ _queue.append((set(self._instance_ids_by_parent_id[None]), True))
+ discarted_ids = set()
+ while _queue:
+ if not instance_ids:
+ break
+
+ chilren_ids, is_parent_active = _queue.pop()
+ for instance_id in chilren_ids:
+ if instance_id not in affected_ids:
+ continue
+
+ widget = self._widgets_by_id[instance_id]
+ if is_parent_active is not widget.is_parent_active():
+ widget.set_parent_active(is_parent_active)
+
+ instance_ids.discard(instance_id)
+ if instance_id in ids_to_toggle:
+ discarted_ids.add(instance_id)
+ old_value = widget.is_active()
+ value = new_value
+ if value is None:
+ value = not old_value
+
+ widget.set_active(value)
+ if widget.is_parent_active():
+ active_by_id[instance_id] = widget.is_active()
+
+ children_ids = self._instance_ids_by_parent_id[instance_id]
+ children = {
+ child_id
+ for child_id in children_ids
+ if child_id not in discarted_ids
+ }
+
+ if children:
+ instance_ids |= children
+ _queue.append((children, widget.is_active()))
+
+ if not instance_ids:
+ break
+
+ if active_by_id:
+ self._controller.set_instances_active_state(active_by_id)
def keyPressEvent(self, event):
if event.key() == QtCore.Qt.Key_Space:
- self._toggle_instances(-1)
+ self._toggle_instances(None)
return True
elif event.key() == QtCore.Qt.Key_Backspace:
- self._toggle_instances(0)
+ self._toggle_instances(False)
return True
elif event.key() == QtCore.Qt.Key_Return:
- self._toggle_instances(1)
+ self._toggle_instances(True)
return True
return super().keyPressEvent(event)
@@ -670,15 +661,25 @@ class InstanceCardView(AbstractInstanceView):
):
output.append(self._context_widget)
- if self._convertor_items_group is not None:
- output.extend(self._convertor_items_group.get_selected_widgets())
-
- for group_widget in self._widgets_by_group.values():
- for widget in group_widget.get_selected_widgets():
- output.append(widget)
+ output.extend(self._get_selected_convertor_widgets())
+ output.extend(self._get_selected_instance_widgets())
return output
- def _get_selected_instance_ids(self):
+ def _get_selected_instance_widgets(self) -> list[InstanceCardWidget]:
+ return [
+ widget
+ for widget in self._widgets_by_id.values()
+ if widget.is_selected
+ ]
+
+ def _get_selected_convertor_widgets(self) -> list[ConvertorItemCardWidget]:
+ return [
+ widget
+ for widget in self._convertor_widgets_by_id.values()
+ if widget.is_selected
+ ]
+
+ def _get_selected_item_ids(self):
output = []
if (
self._context_widget is not None
@@ -686,11 +687,17 @@ class InstanceCardView(AbstractInstanceView):
):
output.append(CONTEXT_ID)
- if self._convertor_items_group is not None:
- output.extend(self._convertor_items_group.get_selected_item_ids())
+ output.extend(
+ conv_id
+ for conv_id, widget in self._widgets_by_id.items()
+ if widget.is_selected
+ )
- for group_widget in self._widgets_by_group.values():
- output.extend(group_widget.get_selected_item_ids())
+ output.extend(
+ widget.id
+ for instance_id, widget in self._widgets_by_id.items()
+ if widget.is_selected
+ )
return output
def refresh(self):
@@ -698,25 +705,102 @@ class InstanceCardView(AbstractInstanceView):
self._make_sure_context_widget_exists()
- self._update_convertor_items_group()
+ self._update_convertors_group()
context_info_by_id = self._controller.get_instances_context_info()
# Prepare instances by group and identifiers by group
instances_by_group = collections.defaultdict(list)
identifiers_by_group = collections.defaultdict(set)
- for instance in self._controller.get_instance_items():
+ identifiers: set[str] = set()
+ instances_by_id = {}
+ parent_id_by_id = {}
+ instance_ids_by_parent_id = collections.defaultdict(set)
+ instance_items = self._controller.get_instance_items()
+ for instance in instance_items:
group_name = instance.group_label
instances_by_group[group_name].append(instance)
identifiers_by_group[group_name].add(
instance.creator_identifier
)
+ identifiers.add(instance.creator_identifier)
+ instances_by_id[instance.id] = instance
+ instance_ids_by_parent_id[instance.parent_instance_id].add(
+ instance.id
+ )
+ parent_id_by_id[instance.id] = instance.parent_instance_id
- # Remove groups that were not found in apassed instances
- for group_name in tuple(self._widgets_by_group.keys()):
- if group_name in instances_by_group:
- continue
+ parent_active_by_id = {
+ instance_id: False
+ for instance_id in instances_by_id
+ }
+ _queue = collections.deque()
+ _queue.append((None, True))
+ while _queue:
+ parent_id, is_parent_active = _queue.popleft()
+ for instance_id in instance_ids_by_parent_id[parent_id]:
+ instance_item = instances_by_id[instance_id]
+ is_active = instance_item.is_active
+ if (
+ not is_parent_active
+ and instance_item.parent_flags & ParentFlags.share_active
+ ):
+ is_active = False
+ parent_active_by_id[instance_id] = is_parent_active
+ _queue.append(
+ (instance_id, is_active)
+ )
+
+ # Remove groups that were not found in passed instances
+ groups_to_remove = (
+ set(self._widgets_by_group) - set(instances_by_group)
+ )
+ ids_to_remove = (
+ set(self._widgets_by_id) - set(instances_by_id)
+ )
+
+ # Sort groups
+ sorted_group_names = list(sorted(instances_by_group.keys()))
+
+ # Keep track of widget indexes
+ # - we start with 1 because Context item as at the top
+ widget_idx = 1
+ if self._convertors_group is not None:
+ widget_idx += 1
+
+ group_by_instance_id = {}
+ instance_ids_by_group_name = collections.defaultdict(list)
+ group_icons = {
+ identifier: self._controller.get_creator_icon(identifier)
+ for identifier in identifiers
+ }
+ for group_name in sorted_group_names:
+ if group_name not in self._widgets_by_group:
+ group_widget = BaseGroupWidget(
+ group_name, self._content_widget
+ )
+ group_widget.double_clicked.connect(self.double_clicked)
+ self._content_layout.insertWidget(widget_idx, group_widget)
+ self._widgets_by_group[group_name] = group_widget
+
+ widget_idx += 1
+
+ instances = instances_by_group[group_name]
+ for instance in instances:
+ group_by_instance_id[instance.id] = group_name
+ instance_ids_by_group_name[group_name].append(instance.id)
+
+ self._update_instance_widgets(
+ group_name,
+ instances,
+ context_info_by_id,
+ parent_active_by_id,
+ group_icons,
+ )
+
+ # Remove empty groups
+ for group_name in groups_to_remove:
widget = self._widgets_by_group.pop(group_name)
widget.setVisible(False)
self._content_layout.removeWidget(widget)
@@ -725,61 +809,89 @@ class InstanceCardView(AbstractInstanceView):
if group_name in self._explicitly_selected_groups:
self._explicitly_selected_groups.remove(group_name)
- # Sort groups
- sorted_group_names = list(sorted(instances_by_group.keys()))
+ for instance_id in ids_to_remove:
+ widget = self._widgets_by_id.pop(instance_id)
+ widget.setVisible(False)
+ widget.deleteLater()
- # Keep track of widget indexes
- # - we start with 1 because Context item as at the top
- widget_idx = 1
- if self._convertor_items_group is not None:
- widget_idx += 1
+ self._parent_id_by_id = parent_id_by_id
+ self._instance_ids_by_parent_id = instance_ids_by_parent_id
+ self._group_name_by_instance_id = group_by_instance_id
+ self._instance_ids_by_group_name = instance_ids_by_group_name
+ self._ordered_groups = sorted_group_names
- for group_name in sorted_group_names:
- group_icons = {
- identifier: self._controller.get_creator_icon(identifier)
- for identifier in identifiers_by_group[group_name]
- }
- if group_name in self._widgets_by_group:
- group_widget = self._widgets_by_group[group_name]
- group_widget.update_icons(group_icons)
-
- else:
- group_widget = InstanceGroupWidget(
- group_icons, group_name, self._content_widget
- )
- group_widget.active_changed.connect(self._on_active_changed)
- group_widget.selected.connect(self._on_widget_selection)
- group_widget.double_clicked.connect(self.double_clicked)
- self._content_layout.insertWidget(widget_idx, group_widget)
- self._widgets_by_group[group_name] = group_widget
-
- widget_idx += 1
- group_widget.update_instances(
- instances_by_group[group_name], context_info_by_id
- )
- group_widget.set_active_toggle_enabled(
- self._active_toggle_enabled
- )
-
- self._update_ordered_group_names()
-
- def has_items(self):
- if self._convertor_items_group is not None:
+ def has_items(self) -> bool:
+ if self._convertors_group is not None:
return True
- if self._widgets_by_group:
+ if self._widgets_by_id:
return True
return False
- def _update_ordered_group_names(self):
- ordered_group_names = [CONTEXT_GROUP]
- for idx in range(self._content_layout.count()):
- if idx > 0:
- item = self._content_layout.itemAt(idx)
- group_widget = item.widget()
- if group_widget is not None:
- ordered_group_names.append(group_widget.group_name)
+ def _update_instance_widgets(
+ self,
+ group_name: str,
+ instances: list[InstanceItem],
+ context_info_by_id: dict[str, InstanceContextInfo],
+ parent_active_by_id: dict[str, bool],
+ group_icons: dict[str, str],
+ ) -> None:
+ """Update instances for the group.
- self._ordered_groups = ordered_group_names
+ Args:
+ instances (list[InstanceItem]): List of instances in
+ CreateContext.
+ context_info_by_id (dict[str, InstanceContextInfo]): Instance
+ context info by instance id.
+ parent_active_by_id (dict[str, bool]): Instance has active parent.
+
+ """
+ # Store instances by id and by product name
+ group_widget: BaseGroupWidget = self._widgets_by_group[group_name]
+ instances_by_id = {}
+ instances_by_product_name = collections.defaultdict(list)
+ for instance in instances:
+ instances_by_id[instance.id] = instance
+ product_name = instance.product_name
+ instances_by_product_name[product_name].append(instance)
+
+ to_remove_ids = set(
+ self._instance_ids_by_group_name[group_name]
+ ) - set(instances_by_id)
+ group_widget.take_widgets(to_remove_ids)
+
+ # Sort instances by product name
+ sorted_product_names = list(sorted(instances_by_product_name.keys()))
+
+ # Add new instances to widget
+ ordered_ids = []
+ widgets_by_id = {}
+ for product_names in sorted_product_names:
+ for instance in instances_by_product_name[product_names]:
+ context_info = context_info_by_id[instance.id]
+ is_parent_active = parent_active_by_id[instance.id]
+ if instance.id in self._widgets_by_id:
+ widget = self._widgets_by_id[instance.id]
+ widget.update_instance(
+ instance, context_info, is_parent_active
+ )
+ else:
+ group_icon = group_icons[instance.creator_identifier]
+ widget = InstanceCardWidget(
+ instance,
+ context_info,
+ is_parent_active,
+ group_icon,
+ group_widget
+ )
+ widget.selected.connect(self._on_widget_selection)
+ widget.active_changed.connect(self._on_active_changed)
+ widget.double_clicked.connect(self.double_clicked)
+ self._widgets_by_id[instance.id] = widget
+
+ ordered_ids.append(instance.id)
+ widgets_by_id[instance.id] = widget
+
+ group_widget.set_widgets(widgets_by_id, ordered_ids)
def _make_sure_context_widget_exists(self):
# Create context item if is not already existing
@@ -797,28 +909,65 @@ class InstanceCardView(AbstractInstanceView):
self.selection_changed.emit()
self._content_layout.insertWidget(0, widget)
- def _update_convertor_items_group(self):
+ def _update_convertors_group(self):
convertor_items = self._controller.get_convertor_items()
- if not convertor_items and self._convertor_items_group is None:
+ if not convertor_items and self._convertors_group is None:
return
+ ids_to_remove = set(self._convertor_widgets_by_id) - set(
+ convertor_items
+ )
+ if ids_to_remove:
+ self._convertors_group.take_widgets(ids_to_remove)
+
+ for conv_id in ids_to_remove:
+ widget = self._convertor_widgets_by_id.pop(conv_id)
+ widget.setVisible(False)
+ widget.deleteLater()
+
if not convertor_items:
- self._convertor_items_group.setVisible(False)
- self._content_layout.removeWidget(self._convertor_items_group)
- self._convertor_items_group.deleteLater()
- self._convertor_items_group = None
+ self._convertors_group.setVisible(False)
+ self._content_layout.removeWidget(self._convertors_group)
+ self._convertors_group.deleteLater()
+ self._convertors_group = None
+ self._convertor_ids = []
+ self._convertor_widgets_by_id = {}
return
- if self._convertor_items_group is None:
- group_widget = ConvertorItemsGroupWidget(
+ if self._convertors_group is None:
+ group_widget = BaseGroupWidget(
CONVERTOR_ITEM_GROUP, self._content_widget
)
- group_widget.selected.connect(self._on_widget_selection)
- group_widget.double_clicked.connect(self.double_clicked)
self._content_layout.insertWidget(1, group_widget)
- self._convertor_items_group = group_widget
+ self._convertors_group = group_widget
- self._convertor_items_group.update_items(convertor_items)
+ # TODO create convertor widgets
+ items_by_label = collections.defaultdict(list)
+ for item in convertor_items.values():
+ items_by_label[item.label].append(item)
+
+ # Sort instances by product name
+ sorted_labels = list(sorted(items_by_label.keys()))
+
+ # Add new instances to widget
+ convertor_ids: list[str] = []
+ widgets_by_id: dict[str, ConvertorItemCardWidget] = {}
+ for label in sorted_labels:
+ for item in items_by_label[label]:
+ convertor_ids.append(item.id)
+ if item.id in self._convertor_widgets_by_id:
+ widget = self._convertor_widgets_by_id[item.id]
+ widget.update_item(item)
+ else:
+ widget = ConvertorItemCardWidget(item, self)
+ widget.selected.connect(self._on_widget_selection)
+ widget.double_clicked.connect(self.double_clicked)
+ self._convertor_widgets_by_id[item.id] = widget
+ widgets_by_id[item.id] = widget
+
+ self._convertors_group.set_widgets(widgets_by_id, convertor_ids)
+ self._convertor_ids = convertor_ids
+ self._convertor_widgets_by_id = widgets_by_id
def refresh_instance_states(self, instance_ids=None):
"""Trigger update of instances on group widgets."""
@@ -828,23 +977,45 @@ class InstanceCardView(AbstractInstanceView):
instance_items_by_id = self._controller.get_instance_items_by_id(
instance_ids
)
- for widget in self._widgets_by_group.values():
- widget.update_instance_values(
- context_info_by_id, instance_items_by_id, instance_ids
- )
+ instance_ids: set[str] = set(instance_items_by_id)
+ available_ids: set[str] = set(instance_items_by_id)
- def _on_active_changed(self, group_name, instance_id, value):
- group_widget = self._widgets_by_group[group_name]
- instance_widget = group_widget.get_widget_by_item_id(instance_id)
- active_state_by_id = {}
- if not instance_widget.is_selected:
- active_state_by_id[instance_id] = value
- else:
- for widget in self._get_selected_widgets():
- if isinstance(widget, InstanceCardWidget):
- active_state_by_id[widget.id] = value
+ affected_ids = self._get_affected_ids(instance_ids)
- self._controller.set_instances_active_state(active_state_by_id)
+ _queue = collections.deque()
+ _queue.append((set(self._instance_ids_by_parent_id[None]), True))
+ while _queue:
+ if not affected_ids:
+ break
+
+ chilren_ids, is_parent_active = _queue.pop()
+ for instance_id in chilren_ids:
+ if instance_id not in affected_ids:
+ continue
+ affected_ids.discard(instance_id)
+ widget = self._widgets_by_id[instance_id]
+ if instance_id in instance_ids:
+ instance_ids.discard(instance_id)
+ if instance_id in available_ids:
+ available_ids.discard(instance_id)
+ widget.update_instance(
+ instance_items_by_id[instance_id],
+ context_info_by_id[instance_id],
+ is_parent_active,
+ )
+ else:
+ widget.set_parent_active(is_parent_active)
+
+ if not affected_ids:
+ break
+
+ children = set(self._instance_ids_by_parent_id[instance_id])
+ if children:
+ instance_ids |= children
+ _queue.append((children, widget.is_active()))
+
+ def _on_active_changed(self, instance_id: str, value: bool) -> None:
+ self._toggle_instances(value, instance_id)
def _on_widget_selection(self, instance_id, group_name, selection_type):
"""Select specific item by instance id.
@@ -857,10 +1028,9 @@ class InstanceCardView(AbstractInstanceView):
else:
if group_name == CONVERTOR_ITEM_GROUP:
- group_widget = self._convertor_items_group
+ new_widget = self._convertor_widgets_by_id[instance_id]
else:
- group_widget = self._widgets_by_group[group_name]
- new_widget = group_widget.get_widget_by_item_id(instance_id)
+ new_widget = self._widgets_by_id[instance_id]
if selection_type == SelectionTypes.clear:
self._select_item_clear(instance_id, group_name, new_widget)
@@ -896,7 +1066,7 @@ class InstanceCardView(AbstractInstanceView):
"""
self._explicitly_selected_instance_ids = (
- self._get_selected_instance_ids()
+ self._get_selected_item_ids()
)
if new_widget.is_selected:
self._explicitly_selected_instance_ids.remove(instance_id)
@@ -905,11 +1075,21 @@ class InstanceCardView(AbstractInstanceView):
if instance_id == CONTEXT_ID:
remove_group = True
else:
+ has_selected_items = False
if group_name == CONVERTOR_ITEM_GROUP:
- group_widget = self._convertor_items_group
+ for widget in self._convertor_widgets_by_id.values():
+ if widget.is_selected:
+ has_selected_items = True
+ break
else:
- group_widget = self._widgets_by_group[group_name]
- if not group_widget.get_selected_widgets():
+ group_ids = self._instance_ids_by_group_name[group_name]
+ for instance_id in group_ids:
+ widget = self._widgets_by_id[instance_id]
+ if widget.is_selected:
+ has_selected_items = True
+ break
+
+ if not has_selected_items:
remove_group = True
if remove_group:
@@ -1021,10 +1201,16 @@ class InstanceCardView(AbstractInstanceView):
sorted_widgets = [self._context_widget]
else:
if name == CONVERTOR_ITEM_GROUP:
- group_widget = self._convertor_items_group
+ sorted_widgets = [
+ self._convertor_widgets_by_id[conv_id]
+ for conv_id in self._convertor_ids
+ ]
else:
- group_widget = self._widgets_by_group[name]
- sorted_widgets = group_widget.get_ordered_widgets()
+ instance_ids = self._instance_ids_by_group_name[name]
+ sorted_widgets = [
+ self._widgets_by_id[instance_id]
+ for instance_id in instance_ids
+ ]
# Change selection based on explicit selection if start group
# was not passed yet
@@ -1136,21 +1322,18 @@ class InstanceCardView(AbstractInstanceView):
def get_selected_items(self):
"""Get selected instance ids and context."""
- convertor_identifiers = []
- instances = []
- selected_widgets = self._get_selected_widgets()
-
- context_selected = False
- for widget in selected_widgets:
- if widget is self._context_widget:
- context_selected = True
-
- elif isinstance(widget, InstanceCardWidget):
- instances.append(widget.id)
-
- elif isinstance(widget, ConvertorItemCardWidget):
- convertor_identifiers.append(widget.identifier)
-
+ context_selected = (
+ self._context_widget is not None
+ and self._context_widget.is_selected
+ )
+ instances = [
+ widget.id
+ for widget in self._get_selected_instance_widgets()
+ ]
+ convertor_identifiers = [
+ widget.identifier
+ for widget in self._get_selected_convertor_widgets()
+ ]
return instances, context_selected, convertor_identifiers
def set_selected_items(
@@ -1182,12 +1365,19 @@ class InstanceCardView(AbstractInstanceView):
is_convertor_group = group_name == CONVERTOR_ITEM_GROUP
if is_convertor_group:
- group_widget = self._convertor_items_group
+ sorted_widgets = [
+ self._convertor_widgets_by_id[conv_id]
+ for conv_id in self._convertor_ids
+ ]
else:
- group_widget = self._widgets_by_group[group_name]
+ instance_ids = self._instance_ids_by_group_name[group_name]
+ sorted_widgets = [
+ self._widgets_by_id[instance_id]
+ for instance_id in instance_ids
+ ]
group_selected = False
- for widget in group_widget.get_ordered_widgets():
+ for widget in sorted_widgets:
select = False
if is_convertor_group:
is_in = widget.identifier in s_convertor_identifiers
@@ -1209,5 +1399,5 @@ class InstanceCardView(AbstractInstanceView):
if self._active_toggle_enabled is enabled:
return
self._active_toggle_enabled = enabled
- for group_widget in self._widgets_by_group.values():
- group_widget.set_active_toggle_enabled(enabled)
+ for widget in self._widgets_by_id.values():
+ widget.set_active_toggle_enabled(enabled)
diff --git a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py
index 969bec11e5..c524b96d5f 100644
--- a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py
+++ b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py
@@ -22,15 +22,26 @@ selection can be enabled disabled using checkbox or keyboard key presses:
...
```
"""
+from __future__ import annotations
+
import collections
+from typing import Optional
from qtpy import QtWidgets, QtCore, QtGui
from ayon_core.style import get_objected_colors
-from ayon_core.tools.utils import NiceCheckbox
-from ayon_core.tools.utils.lib import html_escape, checkstate_int_to_enum
+from ayon_core.pipeline.create import (
+ InstanceContextInfo,
+ ParentFlags,
+)
+
+from ayon_core.tools.utils import NiceCheckbox, BaseClickableFrame
+from ayon_core.tools.utils.lib import html_escape, checkstate_int_to_enum
from ayon_core.tools.publisher.abstract import AbstractPublisherFrontend
+from ayon_core.tools.publisher.models.create import (
+ InstanceItem,
+)
from ayon_core.tools.publisher.constants import (
INSTANCE_ID_ROLE,
SORT_VALUE_ROLE,
@@ -115,7 +126,13 @@ class InstanceListItemWidget(QtWidgets.QWidget):
active_changed = QtCore.Signal(str, bool)
double_clicked = QtCore.Signal()
- def __init__(self, instance, context_info, parent):
+ def __init__(
+ self,
+ instance: InstanceItem,
+ context_info: InstanceContextInfo,
+ parent_is_active: bool,
+ parent: QtWidgets.QWidget,
+ ):
super().__init__(parent)
self._instance_id = instance.id
@@ -131,30 +148,40 @@ class InstanceListItemWidget(QtWidgets.QWidget):
product_name_label.setObjectName("ListViewProductName")
active_checkbox = NiceCheckbox(parent=self)
- active_checkbox.setChecked(instance.is_active)
- active_checkbox.setVisible(not instance.is_mandatory)
layout = QtWidgets.QHBoxLayout(self)
- content_margins = layout.contentsMargins()
- layout.setContentsMargins(content_margins.left() + 2, 0, 2, 0)
+ layout.setContentsMargins(2, 0, 2, 0)
layout.addWidget(product_name_label)
layout.addStretch(1)
layout.addWidget(active_checkbox)
- self.setAttribute(QtCore.Qt.WA_TranslucentBackground)
- product_name_label.setAttribute(QtCore.Qt.WA_TranslucentBackground)
- active_checkbox.setAttribute(QtCore.Qt.WA_TranslucentBackground)
+ for widget in (
+ self,
+ product_name_label,
+ active_checkbox,
+ ):
+ widget.setAttribute(QtCore.Qt.WA_TranslucentBackground)
active_checkbox.stateChanged.connect(self._on_active_change)
self._instance_label_widget = product_name_label
self._active_checkbox = active_checkbox
- self._has_valid_context = None
+ # Instance info
+ self._has_valid_context = context_info.is_valid
+ self._is_mandatory = instance.is_mandatory
+ self._instance_is_active = instance.is_active
+ self._parent_flags = instance.parent_flags
- self._checkbox_enabled = not instance.is_mandatory
+ # Parent active state is fluent and can change
+ self._parent_is_active = parent_is_active
- self._set_valid_property(context_info.is_valid)
+ # Widget logic info
+ self._state = None
+ self._toggle_is_enabled = True
+
+ self._update_style_state()
+ self._update_checkbox_state()
def mouseDoubleClickEvent(self, event):
widget = self.childAt(event.pos())
@@ -162,59 +189,119 @@ class InstanceListItemWidget(QtWidgets.QWidget):
if widget is not self._active_checkbox:
self.double_clicked.emit()
- def _set_valid_property(self, valid):
- if self._has_valid_context == valid:
- return
- self._has_valid_context = valid
- state = ""
- if not valid:
- state = "invalid"
- self._instance_label_widget.setProperty("state", state)
- self._instance_label_widget.style().polish(self._instance_label_widget)
-
- def is_active(self):
+ def is_active(self) -> bool:
"""Instance is activated."""
return self._active_checkbox.isChecked()
- def set_active(self, new_value):
- """Change active state of instance and checkbox."""
- old_value = self.is_active()
- if new_value is None:
- new_value = not old_value
-
- if new_value != old_value:
- self._active_checkbox.blockSignals(True)
- self._active_checkbox.setChecked(new_value)
- self._active_checkbox.blockSignals(False)
-
def is_checkbox_enabled(self) -> bool:
"""Checkbox can be changed by user."""
- return self._checkbox_enabled
+ return (
+ self._used_parent_active()
+ and not self._is_mandatory
+ )
- def update_instance(self, instance, context_info):
+ def set_active_toggle_enabled(self, enabled: bool) -> None:
+ """Toggle can be available for user."""
+ self._toggle_is_enabled = enabled
+ self._update_checkbox_state()
+
+ def set_active(self, new_value: Optional[bool]) -> None:
+ """Change active state of instance and checkbox by user interaction.
+
+ Args:
+ new_value (Optional[bool]): New active state of instance. Toggle
+ if is 'None'.
+
+ """
+ # Do not allow to change state if is mandatory or parent is not active
+ if not self.is_checkbox_enabled():
+ return
+
+ if new_value is None:
+ new_value = not self._active_checkbox.isChecked()
+ # Update instance active state
+ self._instance_is_active = new_value
+ self._set_checked(new_value)
+
+ def update_instance(
+ self,
+ instance: InstanceItem,
+ context_info: InstanceContextInfo,
+ parent_is_active: bool,
+ ) -> None:
"""Update instance object."""
# Check product name
+ self._instance_id = instance.id
label = instance.label
if label != self._instance_label_widget.text():
self._instance_label_widget.setText(html_escape(label))
- # Check active state
- self.set_active(instance.is_active)
- self._set_is_mandatory(instance.is_mandatory)
- # Check valid states
- self._set_valid_property(context_info.is_valid)
+
+ self._is_mandatory = instance.is_mandatory
+ self._instance_is_active = instance.is_active
+ self._has_valid_context = context_info.is_valid
+ self._parent_is_active = parent_is_active
+ self._parent_flags = instance.parent_flags
+
+ self._update_checkbox_state()
+ self._update_style_state()
+
+ def is_parent_active(self) -> bool:
+ return self._parent_is_active
+
+ def _used_parent_active(self) -> bool:
+ parent_enabled = True
+ if self._parent_flags & ParentFlags.share_active:
+ parent_enabled = self._parent_is_active
+ return parent_enabled
+
+ def set_parent_is_active(self, active: bool) -> None:
+ if self._parent_is_active is active:
+ return
+ self._parent_is_active = active
+ self._update_style_state()
+ self._update_checkbox_state()
+
+ def _set_checked(self, checked: bool) -> None:
+ """Change checked state in UI without triggering checkstate change."""
+ old_value = self._active_checkbox.isChecked()
+ if checked is not old_value:
+ self._active_checkbox.blockSignals(True)
+ self._active_checkbox.setChecked(checked)
+ self._active_checkbox.blockSignals(False)
+
+ def _update_style_state(self) -> None:
+ state = ""
+ if not self._used_parent_active():
+ state = "disabled"
+ elif not self._has_valid_context:
+ state = "invalid"
+
+ if state == self._state:
+ return
+ self._state = state
+ self._instance_label_widget.setProperty("state", state)
+ self._instance_label_widget.style().polish(self._instance_label_widget)
+
+ def _update_checkbox_state(self) -> None:
+ parent_enabled = self._used_parent_active()
+
+ self._active_checkbox.setEnabled(
+ self._toggle_is_enabled
+ and not self._is_mandatory
+ and parent_enabled
+ )
+ # Hide checkbox for mandatory instances
+ self._active_checkbox.setVisible(not self._is_mandatory)
+
+ # Visually disable instance if parent is disabled
+ checked = parent_enabled and self._instance_is_active
+ self._set_checked(checked)
def _on_active_change(self):
self.active_changed.emit(
self._instance_id, self._active_checkbox.isChecked()
)
- def set_active_toggle_enabled(self, enabled):
- self._active_checkbox.setEnabled(enabled)
-
- def _set_is_mandatory(self, is_mandatory: bool) -> None:
- self._checkbox_enabled = not is_mandatory
- self._active_checkbox.setVisible(not is_mandatory)
-
class ListContextWidget(QtWidgets.QFrame):
"""Context (or global attributes) widget."""
@@ -241,43 +328,33 @@ class ListContextWidget(QtWidgets.QFrame):
self.double_clicked.emit()
-class InstanceListGroupWidget(QtWidgets.QFrame):
+class InstanceListGroupWidget(BaseClickableFrame):
"""Widget representing group of instances.
- Has collapse/expand indicator, label of group and checkbox modifying all
- of its children.
+ Has label of group and checkbox modifying all of its children.
"""
- expand_changed = QtCore.Signal(str, bool)
toggle_requested = QtCore.Signal(str, int)
+ expand_change_requested = QtCore.Signal(str)
def __init__(self, group_name, parent):
super().__init__(parent)
self.setObjectName("InstanceListGroupWidget")
self.group_name = group_name
- self._expanded = False
-
- expand_btn = QtWidgets.QToolButton(self)
- expand_btn.setObjectName("ArrowBtn")
- expand_btn.setArrowType(QtCore.Qt.RightArrow)
- expand_btn.setMaximumWidth(14)
name_label = QtWidgets.QLabel(group_name, self)
toggle_checkbox = NiceCheckbox(parent=self)
layout = QtWidgets.QHBoxLayout(self)
- layout.setContentsMargins(5, 0, 2, 0)
- layout.addWidget(expand_btn)
+ layout.setContentsMargins(2, 0, 2, 0)
layout.addWidget(
name_label, 1, QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter
)
layout.addWidget(toggle_checkbox, 0)
name_label.setAttribute(QtCore.Qt.WA_TranslucentBackground)
- expand_btn.setAttribute(QtCore.Qt.WA_TranslucentBackground)
- expand_btn.clicked.connect(self._on_expand_clicked)
toggle_checkbox.stateChanged.connect(self._on_checkbox_change)
self._ignore_state_change = False
@@ -285,7 +362,6 @@ class InstanceListGroupWidget(QtWidgets.QFrame):
self._expected_checkstate = None
self.name_label = name_label
- self.expand_btn = expand_btn
self.toggle_checkbox = toggle_checkbox
def set_checkstate(self, state):
@@ -307,26 +383,15 @@ class InstanceListGroupWidget(QtWidgets.QFrame):
return self.toggle_checkbox.checkState()
+ def set_active_toggle_enabled(self, enabled):
+ self.toggle_checkbox.setEnabled(enabled)
+
def _on_checkbox_change(self, state):
if not self._ignore_state_change:
self.toggle_requested.emit(self.group_name, state)
- def _on_expand_clicked(self):
- self.expand_changed.emit(self.group_name, not self._expanded)
-
- def set_expanded(self, expanded):
- """Change icon of collapse/expand identifier."""
- if self._expanded == expanded:
- return
-
- self._expanded = expanded
- if expanded:
- self.expand_btn.setArrowType(QtCore.Qt.DownArrow)
- else:
- self.expand_btn.setArrowType(QtCore.Qt.RightArrow)
-
- def set_active_toggle_enabled(self, enabled):
- self.toggle_checkbox.setEnabled(enabled)
+ def _mouse_release_callback(self):
+ self.expand_change_requested.emit(self.group_name)
class InstanceTreeView(QtWidgets.QTreeView):
@@ -339,24 +404,11 @@ class InstanceTreeView(QtWidgets.QTreeView):
self.setObjectName("InstanceListView")
self.setHeaderHidden(True)
- self.setIndentation(0)
self.setExpandsOnDoubleClick(False)
self.setSelectionMode(
QtWidgets.QAbstractItemView.ExtendedSelection
)
self.viewport().setMouseTracking(True)
- self._pressed_group_index = None
-
- def _expand_item(self, index, expand=None):
- is_expanded = self.isExpanded(index)
- if expand is None:
- expand = not is_expanded
-
- if expand != is_expanded:
- if expand:
- self.expand(index)
- else:
- self.collapse(index)
def get_selected_instance_ids(self):
"""Ids of selected instances."""
@@ -388,53 +440,6 @@ class InstanceTreeView(QtWidgets.QTreeView):
return super().event(event)
- def _mouse_press(self, event):
- """Store index of pressed group.
-
- This is to be able to change state of group and process mouse
- "double click" as 2x "single click".
- """
- if event.button() != QtCore.Qt.LeftButton:
- return
-
- pressed_group_index = None
- pos_index = self.indexAt(event.pos())
- if pos_index.data(IS_GROUP_ROLE):
- pressed_group_index = pos_index
-
- self._pressed_group_index = pressed_group_index
-
- def mousePressEvent(self, event):
- self._mouse_press(event)
- super().mousePressEvent(event)
-
- def mouseDoubleClickEvent(self, event):
- self._mouse_press(event)
- super().mouseDoubleClickEvent(event)
-
- def _mouse_release(self, event, pressed_index):
- if event.button() != QtCore.Qt.LeftButton:
- return False
-
- pos_index = self.indexAt(event.pos())
- if not pos_index.data(IS_GROUP_ROLE) or pressed_index != pos_index:
- return False
-
- if self.state() == QtWidgets.QTreeView.State.DragSelectingState:
- indexes = self.selectionModel().selectedIndexes()
- if len(indexes) != 1 or indexes[0] != pos_index:
- return False
-
- self._expand_item(pos_index)
- return True
-
- def mouseReleaseEvent(self, event):
- pressed_index = self._pressed_group_index
- self._pressed_group_index = None
- result = self._mouse_release(event, pressed_index)
- if not result:
- super().mouseReleaseEvent(event)
-
class InstanceListView(AbstractInstanceView):
"""Widget providing abstract methods of AbstractInstanceView for list view.
@@ -472,18 +477,21 @@ class InstanceListView(AbstractInstanceView):
instance_view.selectionModel().selectionChanged.connect(
self._on_selection_change
)
- instance_view.collapsed.connect(self._on_collapse)
- instance_view.expanded.connect(self._on_expand)
instance_view.toggle_requested.connect(self._on_toggle_request)
instance_view.double_clicked.connect(self.double_clicked)
self._group_items = {}
self._group_widgets = {}
- self._widgets_by_id = {}
+ self._widgets_by_id: dict[str, InstanceListItemWidget] = {}
+ self._items_by_id = {}
+ self._parent_id_by_id = {}
+ self._instance_ids_by_parent_id = collections.defaultdict(set)
# Group by instance id for handling of active state
self._group_by_instance_id = {}
self._context_item = None
self._context_widget = None
+ self._missing_parent_item = None
+ self._parent_grouping = True
self._convertor_group_item = None
self._convertor_group_widget = None
@@ -496,47 +504,17 @@ class InstanceListView(AbstractInstanceView):
self._active_toggle_enabled = True
- def _on_expand(self, index):
- self._update_widget_expand_state(index, True)
-
- def _on_collapse(self, index):
- self._update_widget_expand_state(index, False)
-
- def _update_widget_expand_state(self, index, expanded):
- group_name = index.data(GROUP_ROLE)
- if group_name == CONVERTOR_ITEM_GROUP:
- group_widget = self._convertor_group_widget
- else:
- group_widget = self._group_widgets.get(group_name)
-
- if group_widget:
- group_widget.set_expanded(expanded)
-
- def _on_toggle_request(self, toggle):
+ def _on_toggle_request(self, toggle: int) -> None:
if not self._active_toggle_enabled:
return
- selected_instance_ids = self._instance_view.get_selected_instance_ids()
if toggle == -1:
active = None
elif toggle == 1:
active = True
else:
active = False
-
- group_names = set()
- for instance_id in selected_instance_ids:
- widget = self._widgets_by_id.get(instance_id)
- if widget is None:
- continue
-
- widget.set_active(active)
- group_name = self._group_by_instance_id.get(instance_id)
- if group_name is not None:
- group_names.add(group_name)
-
- for group_name in group_names:
- self._update_group_checkstate(group_name)
+ self._toggle_active_state(active)
def _update_group_checkstate(self, group_name):
"""Update checkstate of one group."""
@@ -545,8 +523,10 @@ class InstanceListView(AbstractInstanceView):
return
activity = None
- for instance_id, _group_name in self._group_by_instance_id.items():
- if _group_name != group_name:
+ for (
+ instance_id, instance_group_name
+ ) in self._group_by_instance_id.items():
+ if instance_group_name != group_name:
continue
instance_widget = self._widgets_by_id.get(instance_id)
@@ -583,14 +563,29 @@ class InstanceListView(AbstractInstanceView):
self._update_convertor_items_group()
context_info_by_id = self._controller.get_instances_context_info()
-
+ instance_items = self._controller.get_instance_items()
# Prepare instances by their groups
instances_by_group_name = collections.defaultdict(list)
+ instances_by_parent_id = collections.defaultdict(list)
+ instance_ids_by_parent_id = collections.defaultdict(set)
group_names = set()
- for instance in self._controller.get_instance_items():
+ instance_ids = set()
+ for instance in instance_items:
+ instance_ids.add(instance.id)
+ instance_ids_by_parent_id[instance.parent_instance_id].add(
+ instance.id
+ )
+ if instance.parent_instance_id:
+ instances_by_parent_id[instance.parent_instance_id].append(
+ instance
+ )
+ if self._parent_grouping:
+ continue
+
group_label = instance.group_label
group_names.add(group_label)
instances_by_group_name[group_label].append(instance)
+ self._group_by_instance_id[instance.id] = group_label
# Create new groups based on prepared `instances_by_group_name`
if self._make_sure_groups_exists(group_names):
@@ -598,95 +593,88 @@ class InstanceListView(AbstractInstanceView):
# Remove groups that are not available anymore
self._remove_groups_except(group_names)
+ self._remove_instances_except(instance_items)
- # Store which groups should be expanded at the end
- expand_groups = set()
+ expand_to_items = []
+ widgets_by_id = {}
+ group_items = [
+ (
+ self._group_widgets[group_name],
+ instances_by_group_name[group_name],
+ group_item,
+ )
+ for group_name, group_item in self._group_items.items()
+ ]
+
+ # Handle orphaned instances
+ missing_parent_ids = set(instances_by_parent_id) - instance_ids
+ if not missing_parent_ids:
+ # Make sure the item is not in view if there are no orhpaned items
+ self._remove_missing_parent_item()
+ else:
+ # Add orphaned group item and append them to 'group_items'
+ orphans_item = self._add_missing_parent_item()
+ for instance_id in missing_parent_ids:
+ group_items.append((
+ None,
+ instances_by_parent_id[instance_id],
+ orphans_item,
+ ))
+
+ items_with_instance = {}
# Process changes in each group item
# - create new instance, update existing and remove not existing
- for group_name, group_item in self._group_items.items():
- # Instance items to remove
- # - will contain all existing instance ids at the start
- # - instance ids may be removed when existing instances are checked
- to_remove = set()
- # Mapping of existing instances under group item
- existing_mapping = {}
+ for group_widget, group_instances, group_item in group_items:
+ # Group widget is not set if is orphaned
+ # - This might need to be changed in future if widget could
+ # be 'None'
+ is_orpaned_item = group_widget is None
- # Get group index to be able to get children indexes
- group_index = self._instance_model.index(
- group_item.row(), group_item.column()
- )
+ # Collect all new instances by parent id
+ # - 'None' is used if parent is group item
+ new_items = collections.defaultdict(list)
+ # Tuples of model item and instance itself
+ for instance in group_instances:
+ _queue = collections.deque()
+ _queue.append((instance, group_item, None))
+ while _queue:
+ instance, parent_item, parent_id = _queue.popleft()
+ instance_id = instance.id
+ # Remove group name from groups mapping
+ if parent_id is not None:
+ self._group_by_instance_id.pop(instance_id, None)
- # Iterate over children indexes of group item
- for idx in range(group_item.rowCount()):
- index = self._instance_model.index(idx, 0, group_index)
- instance_id = index.data(INSTANCE_ID_ROLE)
- # Add all instance into `to_remove` set
- to_remove.add(instance_id)
- existing_mapping[instance_id] = idx
+ # Create new item and store it as new
+ item = self._items_by_id.get(instance_id)
+ if item is None:
+ item = QtGui.QStandardItem()
+ item.setData(instance_id, INSTANCE_ID_ROLE)
+ self._items_by_id[instance_id] = item
+ new_items[parent_id].append(item)
- # Collect all new instances that are not existing under group
- # New items
- new_items = []
- # Tuples of new instance and instance itself
- new_items_with_instance = []
- # Group activity (should be {-1;0;1} at the end)
- # - 0 when all instances are disabled
- # - 1 when all instances are enabled
- # - -1 when it's mixed
- activity = None
- for instance in instances_by_group_name[group_name]:
- instance_id = instance.id
- # Handle group activity
- if activity is None:
- activity = int(instance.is_active)
- elif activity == -1:
- pass
- elif activity != instance.is_active:
- activity = -1
+ elif item.parent() is not parent_item:
+ current_parent = item.parent()
+ if current_parent is not None:
+ current_parent.takeRow(item.row())
+ new_items[parent_id].append(item)
- context_info = context_info_by_id[instance_id]
+ self._parent_id_by_id[instance_id] = parent_id
- self._group_by_instance_id[instance_id] = group_name
- # Remove instance id from `to_remove` if already exists and
- # trigger update of widget
- if instance_id in to_remove:
- to_remove.remove(instance_id)
- widget = self._widgets_by_id[instance_id]
- widget.update_instance(instance, context_info)
- continue
+ items_with_instance[instance.id] = (
+ item,
+ instance,
+ is_orpaned_item,
+ )
- # Create new item and store it as new
- item = QtGui.QStandardItem()
- item.setData(instance.product_name, SORT_VALUE_ROLE)
- item.setData(instance.product_name, GROUP_ROLE)
- item.setData(instance_id, INSTANCE_ID_ROLE)
- new_items.append(item)
- new_items_with_instance.append((item, instance))
+ item.setData(instance.product_name, SORT_VALUE_ROLE)
+ item.setData(instance.product_name, GROUP_ROLE)
- # Set checkstate of group checkbox
- state = QtCore.Qt.PartiallyChecked
- if activity == 0:
- state = QtCore.Qt.Unchecked
- elif activity == 1:
- state = QtCore.Qt.Checked
+ if not self._parent_grouping:
+ continue
- widget = self._group_widgets[group_name]
- widget.set_checkstate(state)
-
- # Remove items that were not found
- idx_to_remove = []
- for instance_id in to_remove:
- idx_to_remove.append(existing_mapping[instance_id])
-
- # Remove them in reverse order to prevent row index changes
- for idx in reversed(sorted(idx_to_remove)):
- group_item.removeRows(idx, 1)
-
- # Cleanup instance related widgets
- for instance_id in to_remove:
- self._group_by_instance_id.pop(instance_id)
- widget = self._widgets_by_id.pop(instance_id)
- widget.deleteLater()
+ children = instances_by_parent_id.pop(instance_id, [])
+ for child in children:
+ _queue.append((child, item, instance_id))
# Process new instance items and add them to model and create
# their widgets
@@ -695,41 +683,106 @@ class InstanceListView(AbstractInstanceView):
sort_at_the_end = True
# Add items under group item
- group_item.appendRows(new_items)
+ for parent_id, items in new_items.items():
+ if parent_id is None or not self._parent_grouping:
+ parent_item = group_item
+ else:
+ parent_item = self._items_by_id[parent_id]
- for item, instance in new_items_with_instance:
- context_info = context_info_by_id[instance.id]
- if not context_info.is_valid:
- expand_groups.add(group_name)
- item_index = self._instance_model.index(
- item.row(),
- item.column(),
- group_index
- )
- proxy_index = self._proxy_model.mapFromSource(item_index)
- widget = InstanceListItemWidget(
- instance, context_info, self._instance_view
- )
- widget.set_active_toggle_enabled(
- self._active_toggle_enabled
- )
- widget.active_changed.connect(self._on_active_changed)
- widget.double_clicked.connect(self.double_clicked)
- self._instance_view.setIndexWidget(proxy_index, widget)
- self._widgets_by_id[instance.id] = widget
+ parent_item.appendRows(items)
- # Trigger sort at the end of refresh
- if sort_at_the_end:
- self._proxy_model.sort(0)
+ ids_order = []
+ ids_queue = collections.deque()
+ ids_queue.extend(instance_ids_by_parent_id[None])
+ while ids_queue:
+ parent_id = ids_queue.popleft()
+ ids_order.append(parent_id)
+ ids_queue.extend(instance_ids_by_parent_id[parent_id])
+ ids_order.extend(set(items_with_instance) - set(ids_order))
- # Expand groups marked for expanding
- for group_name in expand_groups:
- group_item = self._group_items[group_name]
- proxy_index = self._proxy_model.mapFromSource(group_item.index())
+ for instance_id in ids_order:
+ item, instance, is_orpaned_item = items_with_instance[instance_id]
+ context_info = context_info_by_id[instance.id]
+ # TODO expand all parents
+ if not context_info.is_valid:
+ expand_to_items.append(item)
+ parent_active = True
+ if is_orpaned_item:
+ parent_active = False
+
+ parent_id = instance.parent_instance_id
+ if parent_id:
+ parent_widget = widgets_by_id.get(parent_id)
+ parent_active = False
+ if parent_widget is not None:
+ parent_active = parent_widget.is_active()
+ item_index = self._instance_model.indexFromItem(item)
+ proxy_index = self._proxy_model.mapFromSource(item_index)
+ widget = self._instance_view.indexWidget(proxy_index)
+ if isinstance(widget, InstanceListItemWidget):
+ widget.update_instance(
+ instance,
+ context_info,
+ parent_active,
+ )
+ else:
+ widget = InstanceListItemWidget(
+ instance,
+ context_info,
+ parent_active,
+ self._instance_view
+ )
+ widget.active_changed.connect(self._on_active_changed)
+ widget.double_clicked.connect(self.double_clicked)
+ self._instance_view.setIndexWidget(proxy_index, widget)
+ widget.set_active_toggle_enabled(
+ self._active_toggle_enabled
+ )
+
+ widgets_by_id[instance.id] = widget
+ self._widgets_by_id.pop(instance.id, None)
+
+ for widget in self._widgets_by_id.values():
+ widget.setVisible(False)
+ widget.deleteLater()
+
+ self._widgets_by_id = widgets_by_id
+ self._instance_ids_by_parent_id = instance_ids_by_parent_id
+
+ # Set checkstate of group checkbox
+ for group_name in self._group_items:
+ self._update_group_checkstate(group_name)
+
+ # Expand items marked for expanding
+ items_to_expand = []
+ _marked_ids = set()
+ for item in expand_to_items:
+ parent = item.parent()
+ _items = []
+ while True:
+ # Parent is not set or is group (groups are separate)
+ if parent is None or parent.data(IS_GROUP_ROLE):
+ break
+ instance_id = parent.data(INSTANCE_ID_ROLE)
+ # Parent was already marked for expanding
+ if instance_id in _marked_ids:
+ break
+ _marked_ids.add(instance_id)
+ _items.append(parent)
+ parent = parent.parent()
+
+ items_to_expand.extend(reversed(_items))
+
+ for item in items_to_expand:
+ proxy_index = self._proxy_model.mapFromSource(item.index())
self._instance_view.expand(proxy_index)
- def _make_sure_context_item_exists(self):
+ # Trigger sort at the end of refresh
+ if sort_at_the_end:
+ self._proxy_model.sort(0)
+
+ def _make_sure_context_item_exists(self) -> bool:
if self._context_item is not None:
return False
@@ -752,7 +805,7 @@ class InstanceListView(AbstractInstanceView):
self._context_item = context_item
return True
- def _update_convertor_items_group(self):
+ def _update_convertor_items_group(self) -> bool:
created_new_items = False
convertor_items_by_id = self._controller.get_convertor_items()
group_item = self._convertor_group_item
@@ -761,7 +814,7 @@ class InstanceListView(AbstractInstanceView):
root_item = self._instance_model.invisibleRootItem()
if not convertor_items_by_id:
- root_item.removeRow(group_item.row())
+ root_item.takeRow(group_item.row())
self._convertor_group_widget.deleteLater()
self._convertor_group_widget = None
self._convertor_items_by_id = {}
@@ -785,9 +838,7 @@ class InstanceListView(AbstractInstanceView):
CONVERTOR_ITEM_GROUP, self._instance_view
)
widget.toggle_checkbox.setVisible(False)
- widget.expand_changed.connect(
- self._on_convertor_group_expand_request
- )
+
self._instance_view.setIndexWidget(proxy_index, widget)
self._convertor_group_item = group_item
@@ -798,7 +849,7 @@ class InstanceListView(AbstractInstanceView):
child_identifier = child_item.data(CONVERTER_IDENTIFIER_ROLE)
if child_identifier not in convertor_items_by_id:
self._convertor_items_by_id.pop(child_identifier, None)
- group_item.removeRows(row, 1)
+ group_item.takeRow(row)
new_items = []
for identifier, convertor_item in convertor_items_by_id.items():
@@ -820,7 +871,7 @@ class InstanceListView(AbstractInstanceView):
return created_new_items
- def _make_sure_groups_exists(self, group_names):
+ def _make_sure_groups_exists(self, group_names: set[str]) -> bool:
new_group_items = []
for group_name in group_names:
if group_name in self._group_items:
@@ -853,14 +904,16 @@ class InstanceListView(AbstractInstanceView):
widget.set_active_toggle_enabled(
self._active_toggle_enabled
)
- widget.expand_changed.connect(self._on_group_expand_request)
widget.toggle_requested.connect(self._on_group_toggle_request)
+ widget.expand_change_requested.connect(
+ self._on_expand_toggle_request
+ )
self._group_widgets[group_name] = widget
self._instance_view.setIndexWidget(proxy_index, widget)
return True
- def _remove_groups_except(self, group_names):
+ def _remove_groups_except(self, group_names: set[str]) -> None:
# Remove groups that are not available anymore
root_item = self._instance_model.invisibleRootItem()
for group_name in tuple(self._group_items.keys()):
@@ -868,42 +921,197 @@ class InstanceListView(AbstractInstanceView):
continue
group_item = self._group_items.pop(group_name)
- root_item.removeRow(group_item.row())
+ root_item.takeRow(group_item.row())
widget = self._group_widgets.pop(group_name)
+ widget.setVisible(False)
widget.deleteLater()
+ def _remove_instances_except(self, instance_items: list[InstanceItem]):
+ parent_id_by_id = {
+ item.id: item.parent_instance_id
+ for item in instance_items
+ }
+ instance_ids = set(parent_id_by_id)
+ all_removed_ids = set(self._items_by_id) - instance_ids
+ queue = collections.deque()
+ for group_item in self._group_items.values():
+ queue.append((group_item, None))
+ while queue:
+ parent_item, parent_id = queue.popleft()
+ children = [
+ parent_item.child(row)
+ for row in range(parent_item.rowCount())
+ ]
+ for child in children:
+ instance_id = child.data(INSTANCE_ID_ROLE)
+ if instance_id not in parent_id_by_id:
+ parent_item.takeRow(child.row())
+ elif parent_id != parent_id_by_id[instance_id]:
+ parent_item.takeRow(child.row())
+
+ queue.append((child, instance_id))
+
+ for instance_id in all_removed_ids:
+ self._items_by_id.pop(instance_id)
+ self._parent_id_by_id.pop(instance_id)
+ self._group_by_instance_id.pop(instance_id, None)
+ widget = self._widgets_by_id.pop(instance_id, None)
+ if widget is not None:
+ widget.setVisible(False)
+ widget.deleteLater()
+
+ def _add_missing_parent_item(self) -> QtGui.QStandardItem:
+ label = "! Orphaned instances !"
+ if self._missing_parent_item is None:
+ item = QtGui.QStandardItem()
+ item.setData(label, GROUP_ROLE)
+ item.setData("_", SORT_VALUE_ROLE)
+ item.setData(True, IS_GROUP_ROLE)
+ item.setFlags(QtCore.Qt.ItemIsEnabled)
+ self._missing_parent_item = item
+
+ if self._missing_parent_item.row() < 0:
+ root_item = self._instance_model.invisibleRootItem()
+ root_item.appendRow(self._missing_parent_item)
+ index = self._missing_parent_item.index()
+ proxy_index = self._proxy_model.mapFromSource(index)
+ widget = InstanceListGroupWidget(label, self._instance_view)
+ widget.toggle_checkbox.setVisible(False)
+ self._instance_view.setIndexWidget(proxy_index, widget)
+ return self._missing_parent_item
+
+ def _remove_missing_parent_item(self) -> None:
+ if self._missing_parent_item is None:
+ return
+
+ row = self._missing_parent_item.row()
+ if row < 0:
+ return
+
+ parent = self._missing_parent_item.parent()
+ if parent is None:
+ parent = self._instance_model.invisibleRootItem()
+ index = self._missing_parent_item.index()
+ proxy_index = self._proxy_model.mapFromSource(index)
+ widget = self._instance_view.indexWidget(proxy_index)
+ if widget is not None:
+ widget.setVisible(False)
+ widget.deleteLater()
+ parent.takeRow(self._missing_parent_item.row())
+ _queue = collections.deque()
+ _queue.append(self._missing_parent_item)
+ while _queue:
+ item = _queue.popleft()
+ for _ in range(item.rowCount()):
+ child = item.child(0)
+ _queue.append(child)
+ item.takeRow(0)
+
+ self._missing_parent_item = None
+
def refresh_instance_states(self, instance_ids=None):
"""Trigger update of all instances."""
if instance_ids is not None:
instance_ids = set(instance_ids)
- context_info_by_id = self._controller.get_instances_context_info()
+
+ context_info_by_id = self._controller.get_instances_context_info(
+ instance_ids
+ )
instance_items_by_id = self._controller.get_instance_items_by_id(
instance_ids
)
- for instance_id, widget in self._widgets_by_id.items():
- if instance_ids is not None and instance_id not in instance_ids:
- continue
- widget.update_instance(
- instance_items_by_id[instance_id],
- context_info_by_id[instance_id],
- )
+ instance_ids = set(instance_items_by_id)
+ available_ids = set(instance_ids)
+
+ _queue = collections.deque()
+ _queue.append((set(self._instance_ids_by_parent_id[None]), True))
+
+ discarted_ids = set()
+ while _queue:
+ if not instance_ids:
+ break
+
+ children_ids, parent_active = _queue.popleft()
+ for instance_id in children_ids:
+ widget = self._widgets_by_id[instance_id]
+ # Parent active state changed -> traverse children too
+ add_children = False
+ if instance_id in instance_ids:
+ add_children = (
+ parent_active is not widget.is_parent_active()
+ )
+ if instance_id in available_ids:
+ available_ids.discard(instance_id)
+ widget.update_instance(
+ instance_items_by_id[instance_id],
+ context_info_by_id[instance_id],
+ parent_active,
+ )
+
+ instance_ids.discard(instance_id)
+ discarted_ids.add(instance_id)
+
+ if parent_active is not widget.is_parent_active():
+ widget.set_parent_is_active(parent_active)
+ add_children = True
+
+ if not add_children:
+ if not instance_ids:
+ break
+ continue
+
+ _children = set(self._instance_ids_by_parent_id[instance_id])
+ if _children:
+ instance_ids |= _children
+ _queue.append((_children, widget.is_active()))
+
+ if not instance_ids:
+ break
+
+ def parent_grouping_enabled(self) -> bool:
+ return self._parent_grouping
+
+ def set_parent_grouping(self, parent_grouping: bool) -> None:
+ self._parent_grouping = parent_grouping
def _on_active_changed(self, changed_instance_id, new_value):
- selected_instance_ids, _, _ = self.get_selected_items()
+ self._toggle_active_state(new_value, changed_instance_id)
+
+ def _toggle_active_state(
+ self,
+ new_value: Optional[bool],
+ active_id: Optional[str] = None,
+ instance_ids: Optional[set[str]] = None,
+ ) -> None:
+ if instance_ids is None:
+ instance_ids, _, _ = self.get_selected_items()
+ if active_id and active_id not in instance_ids:
+ instance_ids = {active_id}
active_by_id = {}
- found = False
- for instance_id in selected_instance_ids:
- active_by_id[instance_id] = new_value
- if not found and instance_id == changed_instance_id:
- found = True
+ _queue = collections.deque()
+ _queue.append((set(self._instance_ids_by_parent_id[None]), True))
- if not found:
- active_by_id = {changed_instance_id: new_value}
+ while _queue:
+ children_ids, parent_active = _queue.popleft()
+ for instance_id in children_ids:
+ widget = self._widgets_by_id[instance_id]
+ widget.set_parent_is_active(parent_active)
+ if instance_id in instance_ids:
+ value = new_value
+ if value is None:
+ value = not widget.is_active()
+ widget.set_active(value)
+ active_by_id[instance_id] = value
+
+ children = set(
+ self._instance_ids_by_parent_id[instance_id]
+ )
+ if children:
+ _queue.append((children, widget.is_active()))
self._controller.set_instances_active_state(active_by_id)
- self._change_active_instances(active_by_id, new_value)
group_names = set()
for instance_id in active_by_id:
group_name = self._group_by_instance_id.get(instance_id)
@@ -913,93 +1121,55 @@ class InstanceListView(AbstractInstanceView):
for group_name in group_names:
self._update_group_checkstate(group_name)
- def _change_active_instances(self, instance_ids, new_value):
- if not instance_ids:
- return
-
- for instance_id in instance_ids:
- widget = self._widgets_by_id.get(instance_id)
- if widget:
- widget.set_active(new_value)
-
def _on_selection_change(self, *_args):
self.selection_changed.emit()
- def _on_group_expand_request(self, group_name, expanded):
+ def _on_expand_toggle_request(self, group_name):
group_item = self._group_items.get(group_name)
if not group_item:
return
-
- group_index = self._instance_model.index(
- group_item.row(), group_item.column()
- )
- proxy_index = self._proxy_model.mapFromSource(group_index)
- self._instance_view.setExpanded(proxy_index, expanded)
-
- def _on_convertor_group_expand_request(self, _, expanded):
- group_item = self._convertor_group_item
- if not group_item:
- return
- group_index = self._instance_model.index(
- group_item.row(), group_item.column()
- )
- proxy_index = self._proxy_model.mapFromSource(group_index)
- self._instance_view.setExpanded(proxy_index, expanded)
+ proxy_index = self._proxy_model.mapFromSource(group_item.index())
+ new_state = not self._instance_view.isExpanded(proxy_index)
+ self._instance_view.setExpanded(proxy_index, new_state)
def _on_group_toggle_request(self, group_name, state):
state = checkstate_int_to_enum(state)
if state == QtCore.Qt.PartiallyChecked:
return
- if state == QtCore.Qt.Checked:
- active = True
- else:
- active = False
-
group_item = self._group_items.get(group_name)
if not group_item:
return
- active_by_id = {}
- all_changed = True
+ active = state == QtCore.Qt.Checked
+
+ instance_ids = set()
for row in range(group_item.rowCount()):
- item = group_item.child(row)
- instance_id = item.data(INSTANCE_ID_ROLE)
- widget = self._widgets_by_id.get(instance_id)
- if widget is None:
- continue
- if widget.is_checkbox_enabled():
- active_by_id[instance_id] = active
- else:
- all_changed = False
+ child = group_item.child(row)
+ instance_id = child.data(INSTANCE_ID_ROLE)
+ instance_ids.add(instance_id)
- self._controller.set_instances_active_state(active_by_id)
-
- self._change_active_instances(active_by_id, active)
+ self._toggle_active_state(active, instance_ids=instance_ids)
proxy_index = self._proxy_model.mapFromSource(group_item.index())
if not self._instance_view.isExpanded(proxy_index):
self._instance_view.expand(proxy_index)
- if not all_changed:
- # If not all instances were changed, update group checkstate
- self._update_group_checkstate(group_name)
-
- def has_items(self):
+ def has_items(self) -> bool:
if self._convertor_group_widget is not None:
return True
if self._group_items:
return True
return False
- def get_selected_items(self):
+ def get_selected_items(self) -> tuple[list[str], bool, list[str]]:
"""Get selected instance ids and context selection.
Returns:
- tuple: Selected instance ids and boolean if context
- is selected.
- """
+ tuple[list[str], bool, list[str]]: Selected instance ids,
+ boolean if context is selected and selected convertor ids.
+ """
instance_ids = []
convertor_identifiers = []
context_selected = False
@@ -1123,7 +1293,7 @@ class InstanceListView(AbstractInstanceView):
| QtCore.QItemSelectionModel.Rows
)
- def set_active_toggle_enabled(self, enabled):
+ def set_active_toggle_enabled(self, enabled: bool) -> None:
if self._active_toggle_enabled is enabled:
return
diff --git a/client/ayon_core/tools/publisher/widgets/overview_widget.py b/client/ayon_core/tools/publisher/widgets/overview_widget.py
index 46395328e0..01799ac908 100644
--- a/client/ayon_core/tools/publisher/widgets/overview_widget.py
+++ b/client/ayon_core/tools/publisher/widgets/overview_widget.py
@@ -1,3 +1,7 @@
+from __future__ import annotations
+
+from typing import Generator
+
from qtpy import QtWidgets, QtCore
from ayon_core.tools.publisher.abstract import AbstractPublisherFrontend
@@ -6,6 +10,7 @@ from .border_label_widget import BorderedLabelWidget
from .card_view_widgets import InstanceCardView
from .list_view_widgets import InstanceListView
from .widgets import (
+ AbstractInstanceView,
CreateInstanceBtn,
RemoveInstanceBtn,
ChangeViewBtn,
@@ -43,10 +48,16 @@ class OverviewWidget(QtWidgets.QFrame):
product_view_cards = InstanceCardView(controller, product_views_widget)
product_list_view = InstanceListView(controller, product_views_widget)
+ product_list_view.set_parent_grouping(False)
+ product_list_view_grouped = InstanceListView(
+ controller, product_views_widget
+ )
+ product_list_view_grouped.set_parent_grouping(True)
product_views_layout = QtWidgets.QStackedLayout()
product_views_layout.addWidget(product_view_cards)
product_views_layout.addWidget(product_list_view)
+ product_views_layout.addWidget(product_list_view_grouped)
product_views_layout.setCurrentWidget(product_view_cards)
# Buttons at the bottom of product view
@@ -118,6 +129,12 @@ class OverviewWidget(QtWidgets.QFrame):
product_list_view.double_clicked.connect(
self.publish_tab_requested
)
+ product_list_view_grouped.selection_changed.connect(
+ self._on_product_change
+ )
+ product_list_view_grouped.double_clicked.connect(
+ self.publish_tab_requested
+ )
product_view_cards.selection_changed.connect(
self._on_product_change
)
@@ -159,16 +176,22 @@ class OverviewWidget(QtWidgets.QFrame):
"create.model.instance.requirement.changed",
self._on_instance_requirement_changed
)
+ controller.register_event_callback(
+ "create.model.instance.parent.changed",
+ self._on_instance_parent_changed
+ )
self._product_content_widget = product_content_widget
self._product_content_layout = product_content_layout
self._product_view_cards = product_view_cards
self._product_list_view = product_list_view
+ self._product_list_view_grouped = product_list_view_grouped
self._product_views_layout = product_views_layout
self._create_btn = create_btn
self._delete_btn = delete_btn
+ self._change_view_btn = change_view_btn
self._product_attributes_widget = product_attributes_widget
self._create_widget = create_widget
@@ -246,7 +269,7 @@ class OverviewWidget(QtWidgets.QFrame):
)
def has_items(self):
- view = self._product_views_layout.currentWidget()
+ view = self._get_current_view()
return view.has_items()
def _on_create_clicked(self):
@@ -361,17 +384,18 @@ class OverviewWidget(QtWidgets.QFrame):
def _on_instance_requirement_changed(self, event):
self._refresh_instance_states(event["instance_ids"])
- def _refresh_instance_states(self, instance_ids):
- current_idx = self._product_views_layout.currentIndex()
- for idx in range(self._product_views_layout.count()):
- if idx == current_idx:
- continue
- widget = self._product_views_layout.widget(idx)
- if widget.refreshed:
- widget.set_refreshed(False)
+ def _on_instance_parent_changed(self, event):
+ self._refresh_instance_states(event["instance_ids"])
- current_widget = self._product_views_layout.widget(current_idx)
- current_widget.refresh_instance_states(instance_ids)
+ def _refresh_instance_states(self, instance_ids):
+ current_view = self._get_current_view()
+ for view in self._iter_views():
+ if view is current_view:
+ current_view = view
+ elif view.refreshed:
+ view.set_refreshed(False)
+
+ current_view.refresh_instance_states(instance_ids)
def _on_convert_requested(self):
self.convert_requested.emit()
@@ -385,7 +409,7 @@ class OverviewWidget(QtWidgets.QFrame):
convertor plugins.
"""
- view = self._product_views_layout.currentWidget()
+ view = self._get_current_view()
return view.get_selected_items()
def get_selected_legacy_convertors(self):
@@ -400,12 +424,12 @@ class OverviewWidget(QtWidgets.QFrame):
return convertor_identifiers
def _change_view_type(self):
+ old_view = self._get_current_view()
+
idx = self._product_views_layout.currentIndex()
new_idx = (idx + 1) % self._product_views_layout.count()
- old_view = self._product_views_layout.currentWidget()
- new_view = self._product_views_layout.widget(new_idx)
-
+ new_view = self._get_view_by_idx(new_idx)
if not new_view.refreshed:
new_view.refresh()
new_view.set_refreshed(True)
@@ -418,22 +442,52 @@ class OverviewWidget(QtWidgets.QFrame):
new_view.set_selected_items(
instance_ids, context_selected, convertor_identifiers
)
+ view_type = "list"
+ if new_view is self._product_list_view_grouped:
+ view_type = "card"
+ elif new_view is self._product_list_view:
+ view_type = "list-parent-grouping"
+ self._change_view_btn.set_view_type(view_type)
self._product_views_layout.setCurrentIndex(new_idx)
self._on_product_change()
+ def _iter_views(self) -> Generator[AbstractInstanceView, None, None]:
+ for idx in range(self._product_views_layout.count()):
+ widget = self._product_views_layout.widget(idx)
+ if not isinstance(widget, AbstractInstanceView):
+ raise TypeError(
+ "Current widget is not instance of 'AbstractInstanceView'"
+ )
+ yield widget
+
+ def _get_current_view(self) -> AbstractInstanceView:
+ widget = self._product_views_layout.currentWidget()
+ if isinstance(widget, AbstractInstanceView):
+ return widget
+ raise TypeError(
+ "Current widget is not instance of 'AbstractInstanceView'"
+ )
+
+ def _get_view_by_idx(self, idx: int) -> AbstractInstanceView:
+ widget = self._product_views_layout.widget(idx)
+ if isinstance(widget, AbstractInstanceView):
+ return widget
+ raise TypeError(
+ "Current widget is not instance of 'AbstractInstanceView'"
+ )
+
def _refresh_instances(self):
if self._refreshing_instances:
return
self._refreshing_instances = True
- for idx in range(self._product_views_layout.count()):
- widget = self._product_views_layout.widget(idx)
- widget.set_refreshed(False)
+ for view in self._iter_views():
+ view.set_refreshed(False)
- view = self._product_views_layout.currentWidget()
+ view = self._get_current_view()
view.refresh()
view.set_refreshed(True)
@@ -444,25 +498,22 @@ class OverviewWidget(QtWidgets.QFrame):
# Give a change to process Resize Request
QtWidgets.QApplication.processEvents()
- # Trigger update geometry of
- widget = self._product_views_layout.currentWidget()
- widget.updateGeometry()
+ # Trigger update geometry
+ view.updateGeometry()
def _on_publish_start(self):
"""Publish started."""
self._create_btn.setEnabled(False)
self._product_attributes_wrap.setEnabled(False)
- for idx in range(self._product_views_layout.count()):
- widget = self._product_views_layout.widget(idx)
- widget.set_active_toggle_enabled(False)
+ for view in self._iter_views():
+ view.set_active_toggle_enabled(False)
def _on_controller_reset_start(self):
"""Controller reset started."""
- for idx in range(self._product_views_layout.count()):
- widget = self._product_views_layout.widget(idx)
- widget.set_active_toggle_enabled(True)
+ for view in self._iter_views():
+ view.set_active_toggle_enabled(True)
def _on_publish_reset(self):
"""Context in controller has been reseted."""
@@ -477,7 +528,19 @@ class OverviewWidget(QtWidgets.QFrame):
self._refresh_instances()
def _on_instances_added(self):
+ view = self._get_current_view()
+ is_card_view = False
+ count = 0
+ if isinstance(view, InstanceCardView):
+ is_card_view = True
+ count = view.get_current_instance_count()
+
self._refresh_instances()
+ if is_card_view and count < 10:
+ new_count = view.get_current_instance_count()
+ if new_count > count and new_count >= 10:
+ self._change_view_type()
+
def _on_instances_removed(self):
self._refresh_instances()
diff --git a/client/ayon_core/tools/publisher/widgets/widgets.py b/client/ayon_core/tools/publisher/widgets/widgets.py
index a9d34c4c66..793b0f501b 100644
--- a/client/ayon_core/tools/publisher/widgets/widgets.py
+++ b/client/ayon_core/tools/publisher/widgets/widgets.py
@@ -10,6 +10,7 @@ from ayon_core.tools.flickcharm import FlickCharm
from ayon_core.tools.utils import (
IconButton,
PixmapLabel,
+ get_qt_icon,
)
from ayon_core.tools.publisher.constants import ResetKeySequence
@@ -287,12 +288,32 @@ class RemoveInstanceBtn(PublishIconBtn):
self.setToolTip("Remove selected instances")
-class ChangeViewBtn(PublishIconBtn):
- """Create toggle view button."""
+class ChangeViewBtn(IconButton):
+ """Toggle views button."""
def __init__(self, parent=None):
- icon_path = get_icon_path("change_view")
- super().__init__(icon_path, parent)
- self.setToolTip("Swap between views")
+ super().__init__(parent)
+ self.set_view_type("list")
+
+ def set_view_type(self, view_type):
+ if view_type == "list":
+ # icon_name = "data_table"
+ icon_name = "dehaze"
+ tooltip = "Change to list view"
+ elif view_type == "card":
+ icon_name = "view_agenda"
+ tooltip = "Change to card view"
+ else:
+ icon_name = "segment"
+ tooltip = "Change to parent grouping view"
+
+ # "format_align_right"
+ # "segment"
+ icon = get_qt_icon({
+ "type": "material-symbols",
+ "name": icon_name,
+ })
+ self.setIcon(icon)
+ self.setToolTip(tooltip)
class AbstractInstanceView(QtWidgets.QWidget):
@@ -370,6 +391,20 @@ class AbstractInstanceView(QtWidgets.QWidget):
"{} Method 'set_active_toggle_enabled' is not implemented."
).format(self.__class__.__name__))
+ def refresh_instance_states(self, instance_ids=None):
+ """Refresh instance states.
+
+ Args:
+ instance_ids: Optional[Iterable[str]]: Instance ids to refresh.
+ If not passed then all instances are refreshed.
+
+ """
+
+ raise NotImplementedError(
+ f"{self.__class__.__name__} Method 'refresh_instance_states'"
+ " is not implemented."
+ )
+
class ClickableLineEdit(QtWidgets.QLineEdit):
"""QLineEdit capturing left mouse click.
diff --git a/client/ayon_core/tools/sceneinventory/control.py b/client/ayon_core/tools/sceneinventory/control.py
index 60d9bc77a9..45f76a54ac 100644
--- a/client/ayon_core/tools/sceneinventory/control.py
+++ b/client/ayon_core/tools/sceneinventory/control.py
@@ -1,7 +1,7 @@
import ayon_api
from ayon_core.lib.events import QueuedEventSystem
-from ayon_core.host import HostBase
+from ayon_core.host import ILoadHost
from ayon_core.pipeline import (
registered_host,
get_current_context,
@@ -35,7 +35,7 @@ class SceneInventoryController:
self._projects_model = ProjectsModel(self)
self._event_system = self._create_event_system()
- def get_host(self) -> HostBase:
+ def get_host(self) -> ILoadHost:
return self._host
def emit_event(self, topic, data=None, source=None):
diff --git a/client/ayon_core/tools/subsetmanager/README.md b/client/ayon_core/tools/subsetmanager/README.md
deleted file mode 100644
index 35b80ea114..0000000000
--- a/client/ayon_core/tools/subsetmanager/README.md
+++ /dev/null
@@ -1,19 +0,0 @@
-Subset manager
---------------
-
-Simple UI showing list of created subset that will be published via Pyblish.
-Useful for applications (Photoshop, AfterEffects, TVPaint, Harmony) which are
-storing metadata about instance hidden from user.
-
-This UI allows listing all created subset and removal of them if needed (
-in case use doesn't want to publish anymore, its using workfile as a starting
-file for different task and instances should be completely different etc.
-)
-
-Host is expected to implemented:
-- `list_instances` - returning list of dictionaries (instances), must contain
- unique uuid field
- example:
- ```[{"uuid":"15","active":true,"subset":"imageBG","family":"image","id":"ayon.create.instance","asset":"Town"}]```
-- `remove_instance(instance)` - removes instance from file's metadata
- instance is a dictionary, with uuid field
\ No newline at end of file
diff --git a/client/ayon_core/tools/subsetmanager/__init__.py b/client/ayon_core/tools/subsetmanager/__init__.py
deleted file mode 100644
index 6cfca7db66..0000000000
--- a/client/ayon_core/tools/subsetmanager/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-from .window import (
- show,
- SubsetManagerWindow
-)
-
-__all__ = (
- "show",
- "SubsetManagerWindow"
-)
diff --git a/client/ayon_core/tools/subsetmanager/model.py b/client/ayon_core/tools/subsetmanager/model.py
deleted file mode 100644
index 4964abd86d..0000000000
--- a/client/ayon_core/tools/subsetmanager/model.py
+++ /dev/null
@@ -1,56 +0,0 @@
-import uuid
-
-from qtpy import QtCore, QtGui
-
-from ayon_core.pipeline import registered_host
-
-ITEM_ID_ROLE = QtCore.Qt.UserRole + 1
-
-
-class InstanceModel(QtGui.QStandardItemModel):
- def __init__(self, *args, **kwargs):
- super(InstanceModel, self).__init__(*args, **kwargs)
- self._instances_by_item_id = {}
-
- def get_instance_by_id(self, item_id):
- return self._instances_by_item_id.get(item_id)
-
- def refresh(self):
- self.clear()
-
- self._instances_by_item_id = {}
-
- instances = None
- host = registered_host()
- list_instances = getattr(host, "list_instances", None)
- if list_instances:
- instances = list_instances()
-
- if not instances:
- return
-
- items = []
- for instance_data in instances:
- item_id = str(uuid.uuid4())
- product_name = (
- instance_data.get("productName")
- or instance_data.get("subset")
- )
- label = instance_data.get("label") or product_name
- item = QtGui.QStandardItem(label)
- item.setEnabled(True)
- item.setEditable(False)
- item.setData(item_id, ITEM_ID_ROLE)
- items.append(item)
- self._instances_by_item_id[item_id] = instance_data
-
- if items:
- self.invisibleRootItem().appendRows(items)
-
- def headerData(self, section, orientation, role):
- if role == QtCore.Qt.DisplayRole and section == 0:
- return "Instance"
-
- return super(InstanceModel, self).headerData(
- section, orientation, role
- )
diff --git a/client/ayon_core/tools/subsetmanager/widgets.py b/client/ayon_core/tools/subsetmanager/widgets.py
deleted file mode 100644
index 1067474c44..0000000000
--- a/client/ayon_core/tools/subsetmanager/widgets.py
+++ /dev/null
@@ -1,110 +0,0 @@
-import json
-from qtpy import QtWidgets, QtCore
-
-
-class InstanceDetail(QtWidgets.QWidget):
- save_triggered = QtCore.Signal()
-
- def __init__(self, parent=None):
- super(InstanceDetail, self).__init__(parent)
-
- details_widget = QtWidgets.QPlainTextEdit(self)
- details_widget.setObjectName("SubsetManagerDetailsText")
-
- save_btn = QtWidgets.QPushButton("Save", self)
-
- self._block_changes = False
- self._editable = False
- self._item_id = None
-
- layout = QtWidgets.QVBoxLayout(self)
- layout.setContentsMargins(0, 0, 0, 0)
- layout.addWidget(details_widget, 1)
- layout.addWidget(save_btn, 0, QtCore.Qt.AlignRight)
-
- save_btn.clicked.connect(self._on_save_clicked)
- details_widget.textChanged.connect(self._on_text_change)
-
- self._details_widget = details_widget
- self._save_btn = save_btn
-
- self.set_editable(False)
-
- def _on_save_clicked(self):
- if self.is_valid():
- self.save_triggered.emit()
-
- def set_editable(self, enabled=True):
- self._editable = enabled
- self.update_state()
-
- def update_state(self, valid=None):
- editable = self._editable
- if not self._item_id:
- editable = False
-
- self._save_btn.setVisible(editable)
- self._details_widget.setReadOnly(not editable)
- if valid is None:
- valid = self.is_valid()
-
- self._save_btn.setEnabled(valid)
- self._set_invalid_detail(valid)
-
- def _set_invalid_detail(self, valid):
- state = ""
- if not valid:
- state = "invalid"
-
- current_state = self._details_widget.property("state")
- if current_state != state:
- self._details_widget.setProperty("state", state)
- self._details_widget.style().polish(self._details_widget)
-
- def set_details(self, container, item_id):
- self._item_id = item_id
-
- text = "Nothing selected"
- if item_id:
- try:
- text = json.dumps(container, indent=4)
- except Exception:
- text = str(container)
-
- self._block_changes = True
- self._details_widget.setPlainText(text)
- self._block_changes = False
-
- self.update_state()
-
- def instance_data_from_text(self):
- try:
- jsoned = json.loads(self._details_widget.toPlainText())
- except Exception:
- jsoned = None
- return jsoned
-
- def item_id(self):
- return self._item_id
-
- def is_valid(self):
- if not self._item_id:
- return True
-
- value = self._details_widget.toPlainText()
- valid = False
- try:
- jsoned = json.loads(value)
- if jsoned and isinstance(jsoned, dict):
- valid = True
-
- except Exception:
- pass
- return valid
-
- def _on_text_change(self):
- if self._block_changes or not self._item_id:
- return
-
- valid = self.is_valid()
- self.update_state(valid)
diff --git a/client/ayon_core/tools/subsetmanager/window.py b/client/ayon_core/tools/subsetmanager/window.py
deleted file mode 100644
index 164ffa95a7..0000000000
--- a/client/ayon_core/tools/subsetmanager/window.py
+++ /dev/null
@@ -1,218 +0,0 @@
-import os
-import sys
-
-from qtpy import QtWidgets, QtCore
-import qtawesome
-
-from ayon_core import style
-from ayon_core.pipeline import registered_host
-from ayon_core.tools.utils import PlaceholderLineEdit
-from ayon_core.tools.utils.lib import (
- iter_model_rows,
- qt_app_context
-)
-from ayon_core.tools.utils.models import RecursiveSortFilterProxyModel
-from .model import (
- InstanceModel,
- ITEM_ID_ROLE
-)
-from .widgets import InstanceDetail
-
-
-module = sys.modules[__name__]
-module.window = None
-
-
-class SubsetManagerWindow(QtWidgets.QDialog):
- def __init__(self, parent=None):
- super(SubsetManagerWindow, self).__init__(parent=parent)
- self.setWindowTitle("Subset Manager 0.1")
- self.setObjectName("SubsetManager")
- if not parent:
- self.setWindowFlags(
- self.windowFlags() | QtCore.Qt.WindowStaysOnTopHint
- )
-
- self.resize(780, 430)
-
- # Trigger refresh on first called show
- self._first_show = True
-
- left_side_widget = QtWidgets.QWidget(self)
-
- # Header part
- header_widget = QtWidgets.QWidget(left_side_widget)
-
- # Filter input
- filter_input = PlaceholderLineEdit(header_widget)
- filter_input.setPlaceholderText("Filter products..")
-
- # Refresh button
- icon = qtawesome.icon("fa.refresh", color="white")
- refresh_btn = QtWidgets.QPushButton(header_widget)
- refresh_btn.setIcon(icon)
-
- header_layout = QtWidgets.QHBoxLayout(header_widget)
- header_layout.setContentsMargins(0, 0, 0, 0)
- header_layout.addWidget(filter_input)
- header_layout.addWidget(refresh_btn)
-
- # Instances view
- view = QtWidgets.QTreeView(left_side_widget)
- view.setIndentation(0)
- view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
-
- model = InstanceModel(view)
- proxy = RecursiveSortFilterProxyModel()
- proxy.setSourceModel(model)
- proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive)
-
- view.setModel(proxy)
-
- left_side_layout = QtWidgets.QVBoxLayout(left_side_widget)
- left_side_layout.setContentsMargins(0, 0, 0, 0)
- left_side_layout.addWidget(header_widget)
- left_side_layout.addWidget(view)
-
- details_widget = InstanceDetail(self)
-
- layout = QtWidgets.QHBoxLayout(self)
- layout.addWidget(left_side_widget, 0)
- layout.addWidget(details_widget, 1)
-
- filter_input.textChanged.connect(proxy.setFilterFixedString)
- refresh_btn.clicked.connect(self._on_refresh_clicked)
- view.clicked.connect(self._on_activated)
- view.customContextMenuRequested.connect(self.on_context_menu)
- details_widget.save_triggered.connect(self._on_save)
-
- self._model = model
- self._proxy = proxy
- self._view = view
- self._details_widget = details_widget
- self._refresh_btn = refresh_btn
-
- def _on_refresh_clicked(self):
- self.refresh()
-
- def _on_activated(self, index):
- container = None
- item_id = None
- if index.isValid():
- item_id = index.data(ITEM_ID_ROLE)
- container = self._model.get_instance_by_id(item_id)
-
- self._details_widget.set_details(container, item_id)
-
- def _on_save(self):
- host = registered_host()
- if not hasattr(host, "save_instances"):
- print("BUG: Host does not have \"save_instances\" method")
- return
-
- current_index = self._view.selectionModel().currentIndex()
- if not current_index.isValid():
- return
-
- item_id = current_index.data(ITEM_ID_ROLE)
- if item_id != self._details_widget.item_id():
- return
-
- item_data = self._details_widget.instance_data_from_text()
- new_instances = []
- for index in iter_model_rows(self._model, 0):
- _item_id = index.data(ITEM_ID_ROLE)
- if _item_id == item_id:
- instance_data = item_data
- else:
- instance_data = self._model.get_instance_by_id(item_id)
- new_instances.append(instance_data)
-
- host.save_instances(new_instances)
-
- def on_context_menu(self, point):
- point_index = self._view.indexAt(point)
- item_id = point_index.data(ITEM_ID_ROLE)
- instance_data = self._model.get_instance_by_id(item_id)
- if instance_data is None:
- return
-
- # Prepare menu
- menu = QtWidgets.QMenu(self)
- actions = []
- host = registered_host()
- if hasattr(host, "remove_instance"):
- action = QtWidgets.QAction("Remove instance", menu)
- action.setData(host.remove_instance)
- actions.append(action)
-
- if hasattr(host, "select_instance"):
- action = QtWidgets.QAction("Select instance", menu)
- action.setData(host.select_instance)
- actions.append(action)
-
- if not actions:
- actions.append(QtWidgets.QAction("* Nothing to do", menu))
-
- for action in actions:
- menu.addAction(action)
-
- # Show menu under mouse
- global_point = self._view.mapToGlobal(point)
- action = menu.exec_(global_point)
- if not action or not action.data():
- return
-
- # Process action
- # TODO catch exceptions
- function = action.data()
- function(instance_data)
-
- # Reset modified data
- self.refresh()
-
- def refresh(self):
- self._details_widget.set_details(None, None)
- self._model.refresh()
-
- host = registered_host()
- dev_mode = os.environ.get("AVALON_DEVELOP_MODE") or ""
- editable = False
- if dev_mode.lower() in ("1", "yes", "true", "on"):
- editable = hasattr(host, "save_instances")
- self._details_widget.set_editable(editable)
-
- def showEvent(self, *args, **kwargs):
- super(SubsetManagerWindow, self).showEvent(*args, **kwargs)
- if self._first_show:
- self._first_show = False
- self.setStyleSheet(style.load_stylesheet())
- self.refresh()
-
-
-def show(root=None, debug=False, parent=None):
- """Display Scene Inventory GUI
-
- Arguments:
- debug (bool, optional): Run in debug-mode,
- defaults to False
- parent (QtCore.QObject, optional): When provided parent the interface
- to this QObject.
-
- """
-
- try:
- module.window.close()
- del module.window
- except (RuntimeError, AttributeError):
- pass
-
- with qt_app_context():
- window = SubsetManagerWindow(parent)
- window.show()
-
- module.window = window
-
- # Pull window to the front.
- module.window.raise_()
- module.window.activateWindow()
diff --git a/client/ayon_core/tools/utils/host_tools.py b/client/ayon_core/tools/utils/host_tools.py
index 3d356555f3..96b7615e3c 100644
--- a/client/ayon_core/tools/utils/host_tools.py
+++ b/client/ayon_core/tools/utils/host_tools.py
@@ -31,9 +31,7 @@ class HostToolsHelper:
# Prepare attributes for all tools
self._workfiles_tool = None
self._loader_tool = None
- self._creator_tool = None
self._publisher_tool = None
- self._subset_manager_tool = None
self._scene_inventory_tool = None
self._experimental_tools_dialog = None
@@ -96,49 +94,6 @@ class HostToolsHelper:
loader_tool.refresh()
- def get_creator_tool(self, parent):
- """Create, cache and return creator tool window."""
- if self._creator_tool is None:
- from ayon_core.tools.creator import CreatorWindow
-
- creator_window = CreatorWindow(parent=parent or self._parent)
- self._creator_tool = creator_window
-
- return self._creator_tool
-
- def show_creator(self, parent=None):
- """Show tool to create new instantes for publishing."""
- with qt_app_context():
- creator_tool = self.get_creator_tool(parent)
- creator_tool.refresh()
- creator_tool.show()
-
- # Pull window to the front.
- creator_tool.raise_()
- creator_tool.activateWindow()
-
- def get_subset_manager_tool(self, parent):
- """Create, cache and return subset manager tool window."""
- if self._subset_manager_tool is None:
- from ayon_core.tools.subsetmanager import SubsetManagerWindow
-
- subset_manager_window = SubsetManagerWindow(
- parent=parent or self._parent
- )
- self._subset_manager_tool = subset_manager_window
-
- return self._subset_manager_tool
-
- def show_subset_manager(self, parent=None):
- """Show tool display/remove existing created instances."""
- with qt_app_context():
- subset_manager_tool = self.get_subset_manager_tool(parent)
- subset_manager_tool.show()
-
- # Pull window to the front.
- subset_manager_tool.raise_()
- subset_manager_tool.activateWindow()
-
def get_scene_inventory_tool(self, parent):
"""Create, cache and return scene inventory tool window."""
if self._scene_inventory_tool is None:
@@ -261,35 +216,29 @@ class HostToolsHelper:
if tool_name == "workfiles":
return self.get_workfiles_tool(parent, *args, **kwargs)
- elif tool_name == "loader":
+ if tool_name == "loader":
return self.get_loader_tool(parent, *args, **kwargs)
- elif tool_name == "libraryloader":
+ if tool_name == "libraryloader":
return self.get_library_loader_tool(parent, *args, **kwargs)
- elif tool_name == "creator":
- return self.get_creator_tool(parent, *args, **kwargs)
-
- elif tool_name == "subsetmanager":
- return self.get_subset_manager_tool(parent, *args, **kwargs)
-
- elif tool_name == "sceneinventory":
+ if tool_name == "sceneinventory":
return self.get_scene_inventory_tool(parent, *args, **kwargs)
- elif tool_name == "publish":
- self.log.info("Can't return publish tool window.")
-
- # "new" publisher
- elif tool_name == "publisher":
+ if tool_name == "publisher":
return self.get_publisher_tool(parent, *args, **kwargs)
- elif tool_name == "experimental_tools":
+ if tool_name == "experimental_tools":
return self.get_experimental_tools_dialog(parent, *args, **kwargs)
- else:
- self.log.warning(
- "Can't show unknown tool name: \"{}\"".format(tool_name)
- )
+ if tool_name == "publish":
+ self.log.info("Can't return publish tool window.")
+ return None
+
+ self.log.warning(
+ "Can't show unknown tool name: \"{}\"".format(tool_name)
+ )
+ return None
def show_tool_by_name(self, tool_name, parent=None, *args, **kwargs):
"""Show tool by it's name.
@@ -305,12 +254,6 @@ class HostToolsHelper:
elif tool_name == "libraryloader":
self.show_library_loader(parent, *args, **kwargs)
- elif tool_name == "creator":
- self.show_creator(parent, *args, **kwargs)
-
- elif tool_name == "subsetmanager":
- self.show_subset_manager(parent, *args, **kwargs)
-
elif tool_name == "sceneinventory":
self.show_scene_inventory(parent, *args, **kwargs)
@@ -379,14 +322,6 @@ def show_library_loader(parent=None):
_SingletonPoint.show_tool_by_name("libraryloader", parent)
-def show_creator(parent=None):
- _SingletonPoint.show_tool_by_name("creator", parent)
-
-
-def show_subset_manager(parent=None):
- _SingletonPoint.show_tool_by_name("subsetmanager", parent)
-
-
def show_scene_inventory(parent=None):
_SingletonPoint.show_tool_by_name("sceneinventory", parent)
diff --git a/client/ayon_core/tools/workfiles/models/workfiles.py b/client/ayon_core/tools/workfiles/models/workfiles.py
index d33a532222..5b5591fe43 100644
--- a/client/ayon_core/tools/workfiles/models/workfiles.py
+++ b/client/ayon_core/tools/workfiles/models/workfiles.py
@@ -14,7 +14,6 @@ from ayon_core.lib import (
Logger,
)
from ayon_core.host import (
- HostBase,
IWorkfileHost,
WorkfileInfo,
PublishedWorkfileInfo,
@@ -49,19 +48,15 @@ if typing.TYPE_CHECKING:
_NOT_SET = object()
-class HostType(HostBase, IWorkfileHost):
- pass
-
-
class WorkfilesModel:
"""Workfiles model."""
def __init__(
self,
- host: HostType,
+ host: IWorkfileHost,
controller: AbstractWorkfilesBackend
):
- self._host: HostType = host
+ self._host: IWorkfileHost = host
self._controller: AbstractWorkfilesBackend = controller
self._log = Logger.get_logger("WorkfilesModel")