diff --git a/README.md b/README.md index 6b4495c9b6..209af24c75 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ The main things you will need to run and build OpenPype are: - PowerShell 5.0+ (Windows) - Bash (Linux) - [**Python 3.7.8**](#python) or higher -- [**MongoDB**](#database) +- [**MongoDB**](#database) (needed only for local development) It can be built and ran on all common platforms. We develop and test on the following: @@ -126,6 +126,16 @@ pyenv local 3.7.9 ### Linux +#### Docker +Easiest way to build OpenPype on Linux is using [Docker](https://www.docker.com/). Just run: + +```sh +sudo ./tools/docker_build.sh +``` + +If all is successful, you'll find built OpenPype in `./build/` folder. + +#### Manual build You will need [Python 3.7](https://www.python.org/downloads/) and [git](https://git-scm.com/downloads). You'll also need [curl](https://curl.se) on systems that doesn't have one preinstalled. To build Python related stuff, you need Python header files installed (`python3-dev` on Ubuntu for example). @@ -133,7 +143,6 @@ To build Python related stuff, you need Python header files installed (`python3- You'll need also other tools to build some OpenPype dependencies like [CMake](https://cmake.org/). Python 3 should be part of all modern distributions. You can use your package manager to install **git** and **cmake**. -
Details for Ubuntu Install git, cmake and curl diff --git a/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py b/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py index 2f8f9ae91b..c1c2be4855 100644 --- a/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py +++ b/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py @@ -47,7 +47,7 @@ class CollectWorkfile(pyblish.api.ContextPlugin): "subset": subset, "label": scene_file, "family": family, - "families": [family, "ftrack"], + "families": [family], "representations": list() }) diff --git a/openpype/hosts/maya/api/__init__.py b/openpype/hosts/maya/api/__init__.py index 4697d212de..9219da407f 100644 --- a/openpype/hosts/maya/api/__init__.py +++ b/openpype/hosts/maya/api/__init__.py @@ -26,6 +26,12 @@ INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") def install(): + from openpype.settings import get_project_settings + + project_settings = get_project_settings(os.getenv("AVALON_PROJECT")) + # process path mapping + process_dirmap(project_settings) + pyblish.register_plugin_path(PUBLISH_PATH) avalon.register_plugin_path(avalon.Loader, LOAD_PATH) avalon.register_plugin_path(avalon.Creator, CREATE_PATH) @@ -53,6 +59,40 @@ def install(): avalon.data["familiesStateToggled"] = ["imagesequence"] +def process_dirmap(project_settings): + # type: (dict) -> None + """Go through all paths in Settings and set them using `dirmap`. + + Args: + project_settings (dict): Settings for current project. + + """ + if not project_settings["maya"].get("maya-dirmap"): + return + mapping = project_settings["maya"]["maya-dirmap"]["paths"] or {} + mapping_enabled = project_settings["maya"]["maya-dirmap"]["enabled"] + if not mapping or not mapping_enabled: + return + if mapping.get("source-path") and mapping_enabled is True: + log.info("Processing directory mapping ...") + cmds.dirmap(en=True) + for k, sp in enumerate(mapping["source-path"]): + try: + print("{} -> {}".format(sp, mapping["destination-path"][k])) + cmds.dirmap(m=(sp, mapping["destination-path"][k])) + cmds.dirmap(m=(mapping["destination-path"][k], sp)) + except IndexError: + # missing corresponding destination path + log.error(("invalid dirmap mapping, missing corresponding" + " destination directory.")) + break + except RuntimeError: + log.error("invalid path {} -> {}, mapping not registered".format( + sp, mapping["destination-path"][k] + )) + continue + + def uninstall(): pyblish.deregister_plugin_path(PUBLISH_PATH) avalon.deregister_plugin_path(avalon.Loader, LOAD_PATH) diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py b/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py index 5611591b56..b0d3ec6241 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py @@ -51,7 +51,6 @@ class ExtractReviewDataLut(openpype.api.Extractor): if "render.farm" in families: instance.data["families"].remove("review") - instance.data["families"].remove("ftrack") self.log.debug( "_ lutPath: {}".format(instance.data["lutPath"])) diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py index 5032e602a2..cea7d86c26 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py @@ -45,7 +45,6 @@ class ExtractReviewDataMov(openpype.api.Extractor): if "render.farm" in families: instance.data["families"].remove("review") - instance.data["families"].remove("ftrack") data = exporter.generate_mov(farm=True) self.log.debug( diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index a652da7786..6b52e4b387 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -199,7 +199,7 @@ def get_renderer_variables(renderlayer, root): if extension is None: extension = "png" - if extension == "exr (multichannel)" or extension == "exr (deep)": + if extension in ["exr (multichannel)", "exr (deep)"]: extension = "exr" prefix_attr = "vraySettings.fileNamePrefix" @@ -295,57 +295,70 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): instance.data["toBeRenderedOn"] = "deadline" filepath = None + patches = ( + context.data["project_settings"].get( + "deadline", {}).get( + "publish", {}).get( + "MayaSubmitDeadline", {}).get( + "scene_patches", {}) + ) # Handle render/export from published scene or not ------------------ if self.use_published: + patched_files = [] for i in context: - if "workfile" in i.data["families"]: - assert i.data["publish"] is True, ( - "Workfile (scene) must be published along") - template_data = i.data.get("anatomyData") - rep = i.data.get("representations")[0].get("name") - template_data["representation"] = rep - template_data["ext"] = rep - template_data["comment"] = None - anatomy_filled = anatomy.format(template_data) - template_filled = anatomy_filled["publish"]["path"] - filepath = os.path.normpath(template_filled) - self.log.info("Using published scene for render {}".format( - filepath)) + if "workfile" not in i.data["families"]: + continue + assert i.data["publish"] is True, ( + "Workfile (scene) must be published along") + template_data = i.data.get("anatomyData") + rep = i.data.get("representations")[0].get("name") + template_data["representation"] = rep + template_data["ext"] = rep + template_data["comment"] = None + anatomy_filled = anatomy.format(template_data) + template_filled = anatomy_filled["publish"]["path"] + filepath = os.path.normpath(template_filled) + self.log.info("Using published scene for render {}".format( + filepath)) - if not os.path.exists(filepath): - self.log.error("published scene does not exist!") - raise - # now we need to switch scene in expected files - # because token will now point to published - # scene file and that might differ from current one - new_scene = os.path.splitext( - os.path.basename(filepath))[0] - orig_scene = os.path.splitext( - os.path.basename(context.data["currentFile"]))[0] - exp = instance.data.get("expectedFiles") + if not os.path.exists(filepath): + self.log.error("published scene does not exist!") + raise + # now we need to switch scene in expected files + # because token will now point to published + # scene file and that might differ from current one + new_scene = os.path.splitext( + os.path.basename(filepath))[0] + orig_scene = os.path.splitext( + os.path.basename(context.data["currentFile"]))[0] + exp = instance.data.get("expectedFiles") - if isinstance(exp[0], dict): - # we have aovs and we need to iterate over them - new_exp = {} - for aov, files in exp[0].items(): - replaced_files = [] - for f in files: - replaced_files.append( - f.replace(orig_scene, new_scene) - ) - new_exp[aov] = replaced_files - instance.data["expectedFiles"] = [new_exp] - else: - new_exp = [] - for f in exp: - new_exp.append( + if isinstance(exp[0], dict): + # we have aovs and we need to iterate over them + new_exp = {} + for aov, files in exp[0].items(): + replaced_files = [] + for f in files: + replaced_files.append( f.replace(orig_scene, new_scene) ) - instance.data["expectedFiles"] = [new_exp] - self.log.info("Scene name was switched {} -> {}".format( - orig_scene, new_scene - )) + new_exp[aov] = replaced_files + instance.data["expectedFiles"] = [new_exp] + else: + new_exp = [] + for f in exp: + new_exp.append( + f.replace(orig_scene, new_scene) + ) + instance.data["expectedFiles"] = [new_exp] + self.log.info("Scene name was switched {} -> {}".format( + orig_scene, new_scene + )) + # patch workfile is needed + if filepath not in patched_files: + patched_file = self._patch_workfile(filepath, patches) + patched_files.append(patched_file) all_instances = [] for result in context.data["results"]: @@ -868,10 +881,11 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): payload["JobInfo"].update(job_info_ext) payload["PluginInfo"].update(plugin_info_ext) - envs = [] - for k, v in payload["JobInfo"].items(): - if k.startswith("EnvironmentKeyValue"): - envs.append(v) + envs = [ + v + for k, v in payload["JobInfo"].items() + if k.startswith("EnvironmentKeyValue") + ] # add app name to environment envs.append( @@ -892,11 +906,8 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): envs.append( "OPENPYPE_ASS_EXPORT_STEP={}".format(1)) - i = 0 - for e in envs: + for i, e in enumerate(envs): payload["JobInfo"]["EnvironmentKeyValue{}".format(i)] = e - i += 1 - return payload def _get_vray_render_payload(self, data): @@ -1003,7 +1014,7 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): """ if 'verify' not in kwargs: - kwargs['verify'] = False if os.getenv("OPENPYPE_DONT_VERIFY_SSL", True) else True # noqa + kwargs['verify'] = not os.getenv("OPENPYPE_DONT_VERIFY_SSL", True) # add 10sec timeout before bailing out kwargs['timeout'] = 10 return requests.post(*args, **kwargs) @@ -1022,7 +1033,7 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): """ if 'verify' not in kwargs: - kwargs['verify'] = False if os.getenv("OPENPYPE_DONT_VERIFY_SSL", True) else True # noqa + kwargs['verify'] = not os.getenv("OPENPYPE_DONT_VERIFY_SSL", True) # add 10sec timeout before bailing out kwargs['timeout'] = 10 return requests.get(*args, **kwargs) @@ -1069,3 +1080,43 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): result = filename_zero.replace("\\", "/") return result + + def _patch_workfile(self, file, patches): + # type: (str, dict) -> [str, None] + """Patch Maya scene. + + This will take list of patches (lines to add) and apply them to + *published* Maya scene file (that is used later for rendering). + + Patches are dict with following structure:: + { + "name": "Name of patch", + "regex": "regex of line before patch", + "line": "line to insert" + } + + Args: + file (str): File to patch. + patches (dict): Dictionary defining patches. + + Returns: + str: Patched file path or None + + """ + if os.path.splitext(file)[1].lower() != ".ma" or not patches: + return None + + compiled_regex = [re.compile(p["regex"]) for p in patches] + with open(file, "r+") as pf: + scene_data = pf.readlines() + for ln, line in enumerate(scene_data): + for i, r in enumerate(compiled_regex): + if re.match(r, line): + scene_data.insert(ln + 1, patches[i]["line"]) + pf.seek(0) + pf.writelines(scene_data) + pf.truncate() + self.log.info( + "Applied {} patch to scene.".format( + patches[i]["name"])) + return file diff --git a/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py b/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py index c71b5106ec..305c71b035 100644 --- a/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py +++ b/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py @@ -181,6 +181,10 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): """Returns set of file names from metadata.json""" expected_files = set() - for file_name in repre["files"]: + files = repre["files"] + if not isinstance(files, list): + files = [files] + + for file_name in files: expected_files.add(file_name) return expected_files diff --git a/openpype/modules/ftrack/plugins/publish/collect_ftrack_family.py b/openpype/modules/ftrack/plugins/publish/collect_ftrack_family.py index 8464a43ef7..cc2a5b7d37 100644 --- a/openpype/modules/ftrack/plugins/publish/collect_ftrack_family.py +++ b/openpype/modules/ftrack/plugins/publish/collect_ftrack_family.py @@ -63,8 +63,9 @@ class CollectFtrackFamily(pyblish.api.InstancePlugin): self.log.debug("Adding ftrack family for '{}'". format(instance.data.get("family"))) - if families and "ftrack" not in families: - instance.data["families"].append("ftrack") + if families: + if "ftrack" not in families: + instance.data["families"].append("ftrack") else: instance.data["families"] = ["ftrack"] else: diff --git a/openpype/plugins/publish/validate_editorial_asset_name.py b/openpype/plugins/publish/validate_editorial_asset_name.py index f13e3b4f38..eebba61af3 100644 --- a/openpype/plugins/publish/validate_editorial_asset_name.py +++ b/openpype/plugins/publish/validate_editorial_asset_name.py @@ -16,6 +16,7 @@ class ValidateEditorialAssetName(pyblish.api.ContextPlugin): def process(self, context): asset_and_parents = self.get_parents(context) + self.log.debug("__ asset_and_parents: {}".format(asset_and_parents)) if not io.Session: io.install() @@ -25,7 +26,8 @@ class ValidateEditorialAssetName(pyblish.api.ContextPlugin): self.log.debug("__ db_assets: {}".format(db_assets)) asset_db_docs = { - str(e["name"]): e["data"]["parents"] for e in db_assets} + str(e["name"]): e["data"]["parents"] + for e in db_assets} self.log.debug("__ project_entities: {}".format( pformat(asset_db_docs))) @@ -107,6 +109,7 @@ class ValidateEditorialAssetName(pyblish.api.ContextPlugin): parents = instance.data["parents"] return_dict.update({ - asset: [p["entity_name"] for p in parents] + asset: [p["entity_name"] for p in parents + if p["entity_type"].lower() != "project"] }) return return_dict diff --git a/openpype/settings/defaults/project_settings/deadline.json b/openpype/settings/defaults/project_settings/deadline.json index 0f2da9f5b0..efeafbb1ac 100644 --- a/openpype/settings/defaults/project_settings/deadline.json +++ b/openpype/settings/defaults/project_settings/deadline.json @@ -45,7 +45,8 @@ "group": "none", "limit": [], "jobInfo": {}, - "pluginInfo": {} + "pluginInfo": {}, + "scene_patches": [] }, "NukeSubmitDeadline": { "enabled": true, diff --git a/openpype/settings/defaults/project_settings/ftrack.json b/openpype/settings/defaults/project_settings/ftrack.json index 9fa78ac588..692176a585 100644 --- a/openpype/settings/defaults/project_settings/ftrack.json +++ b/openpype/settings/defaults/project_settings/ftrack.json @@ -304,7 +304,8 @@ "aftereffects" ], "families": [ - "render" + "render", + "workfile" ], "tasks": [], "add_ftrack_family": true, diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index 1db6cdf9f1..592b424fd8 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -7,6 +7,19 @@ "workfile": "ma", "yetiRig": "ma" }, + "maya-dirmap": { + "enabled": true, + "paths": { + "source-path": [ + "foo1", + "foo2" + ], + "destination-path": [ + "bar1", + "bar2" + ] + } + }, "scriptsmenu": { "name": "OpenPype Tools", "definition": [ diff --git a/openpype/settings/defaults/project_settings/unreal.json b/openpype/settings/defaults/project_settings/unreal.json index 46b9ca2a18..dad61cd1f0 100644 --- a/openpype/settings/defaults/project_settings/unreal.json +++ b/openpype/settings/defaults/project_settings/unreal.json @@ -1,6 +1,5 @@ { "project_setup": { - "dev_mode": true, - "install_unreal_python_engine": false + "dev_mode": true } } \ No newline at end of file diff --git a/openpype/settings/entities/base_entity.py b/openpype/settings/entities/base_entity.py index b4ebe885f5..851684520b 100644 --- a/openpype/settings/entities/base_entity.py +++ b/openpype/settings/entities/base_entity.py @@ -174,6 +174,14 @@ class BaseItemEntity(BaseEntity): roles = [roles] self.roles = roles + @abstractmethod + def collect_static_entities_by_path(self): + """Collect all paths of all static path entities. + + Static path is entity which is not dynamic or under dynamic entity. + """ + pass + @property def require_restart_on_change(self): return self._require_restart_on_change diff --git a/openpype/settings/entities/dict_conditional.py b/openpype/settings/entities/dict_conditional.py index d275d8ac3d..988464d059 100644 --- a/openpype/settings/entities/dict_conditional.py +++ b/openpype/settings/entities/dict_conditional.py @@ -141,6 +141,7 @@ class DictConditionalEntity(ItemEntity): self.enum_key = self.schema_data.get("enum_key") self.enum_label = self.schema_data.get("enum_label") self.enum_children = self.schema_data.get("enum_children") + self.enum_default = self.schema_data.get("enum_default") self.enum_entity = None @@ -277,15 +278,22 @@ class DictConditionalEntity(ItemEntity): if isinstance(item, dict) and "key" in item: valid_enum_items.append(item) + enum_keys = [] enum_items = [] for item in valid_enum_items: item_key = item["key"] + enum_keys.append(item_key) item_label = item.get("label") or item_key enum_items.append({item_key: item_label}) if not enum_items: return + if self.enum_default in enum_keys: + default_key = self.enum_default + else: + default_key = enum_keys[0] + # Create Enum child first enum_key = self.enum_key or "invalid" enum_schema = { @@ -293,7 +301,8 @@ class DictConditionalEntity(ItemEntity): "multiselection": False, "enum_items": enum_items, "key": enum_key, - "label": self.enum_label + "label": self.enum_label, + "default": default_key } enum_entity = self.create_schema_object(enum_schema, self) @@ -318,6 +327,11 @@ class DictConditionalEntity(ItemEntity): self.non_gui_children[item_key][child_obj.key] = child_obj + def collect_static_entities_by_path(self): + if self.is_dynamic_item or self.is_in_dynamic_item: + return {} + return {self.path: self} + def get_child_path(self, child_obj): """Get hierarchical path of child entity. diff --git a/openpype/settings/entities/dict_immutable_keys_entity.py b/openpype/settings/entities/dict_immutable_keys_entity.py index bde5304787..73b08f101a 100644 --- a/openpype/settings/entities/dict_immutable_keys_entity.py +++ b/openpype/settings/entities/dict_immutable_keys_entity.py @@ -203,6 +203,18 @@ class DictImmutableKeysEntity(ItemEntity): ) self.show_borders = self.schema_data.get("show_borders", True) + def collect_static_entities_by_path(self): + output = {} + if self.is_dynamic_item or self.is_in_dynamic_item: + return output + + output[self.path] = self + for children in self.non_gui_children.values(): + result = children.collect_static_entities_by_path() + if result: + output.update(result) + return output + def get_child_path(self, child_obj): """Get hierarchical path of child entity. diff --git a/openpype/settings/entities/enum_entity.py b/openpype/settings/entities/enum_entity.py index 4f6a2886bc..361ad38dc5 100644 --- a/openpype/settings/entities/enum_entity.py +++ b/openpype/settings/entities/enum_entity.py @@ -73,21 +73,41 @@ class EnumEntity(BaseEnumEntity): def _item_initalization(self): self.multiselection = self.schema_data.get("multiselection", False) self.enum_items = self.schema_data.get("enum_items") + # Default is optional and non breaking attribute + enum_default = self.schema_data.get("default") - valid_keys = set() + all_keys = [] for item in self.enum_items or []: - valid_keys.add(tuple(item.keys())[0]) + key = tuple(item.keys())[0] + all_keys.append(key) - self.valid_keys = valid_keys + self.valid_keys = set(all_keys) if self.multiselection: self.valid_value_types = (list, ) - self.value_on_not_set = [] + value_on_not_set = [] + if enum_default: + if not isinstance(enum_default, list): + enum_default = [enum_default] + + for item in enum_default: + if item in all_keys: + value_on_not_set.append(item) + + self.value_on_not_set = value_on_not_set + else: - for key in valid_keys: - if self.value_on_not_set is NOT_SET: - self.value_on_not_set = key - break + if isinstance(enum_default, list) and enum_default: + enum_default = enum_default[0] + + if enum_default in self.valid_keys: + self.value_on_not_set = enum_default + + else: + for key in all_keys: + if self.value_on_not_set is NOT_SET: + self.value_on_not_set = key + break self.valid_value_types = (STRING_TYPE, ) diff --git a/openpype/settings/entities/input_entities.py b/openpype/settings/entities/input_entities.py index 6952529963..336d1f5c1e 100644 --- a/openpype/settings/entities/input_entities.py +++ b/openpype/settings/entities/input_entities.py @@ -53,6 +53,11 @@ class EndpointEntity(ItemEntity): def _settings_value(self): pass + def collect_static_entities_by_path(self): + if self.is_dynamic_item or self.is_in_dynamic_item: + return {} + return {self.path: self} + def settings_value(self): if self._override_state is OverrideState.NOT_DEFINED: return NOT_SET diff --git a/openpype/settings/entities/item_entities.py b/openpype/settings/entities/item_entities.py index 7e84f8c801..ac6b3e76dd 100644 --- a/openpype/settings/entities/item_entities.py +++ b/openpype/settings/entities/item_entities.py @@ -106,6 +106,9 @@ class PathEntity(ItemEntity): self.valid_value_types = valid_value_types self.child_obj = self.create_schema_object(item_schema, self) + def collect_static_entities_by_path(self): + return self.child_obj.collect_static_entities_by_path() + def get_child_path(self, _child_obj): return self.path @@ -192,6 +195,24 @@ class PathEntity(ItemEntity): class ListStrictEntity(ItemEntity): schema_types = ["list-strict"] + def __getitem__(self, idx): + if not isinstance(idx, int): + idx = int(idx) + return self.children[idx] + + def __setitem__(self, idx, value): + if not isinstance(idx, int): + idx = int(idx) + self.children[idx].set(value) + + def get(self, idx, default=None): + if not isinstance(idx, int): + idx = int(idx) + + if idx < len(self.children): + return self.children[idx] + return default + def _item_initalization(self): self.valid_value_types = (list, ) self.require_key = True @@ -222,6 +243,18 @@ class ListStrictEntity(ItemEntity): super(ListStrictEntity, self).schema_validations() + def collect_static_entities_by_path(self): + output = {} + if self.is_dynamic_item or self.is_in_dynamic_item: + return output + + output[self.path] = self + for child_obj in self.children: + result = child_obj.collect_static_entities_by_path() + if result: + output.update(result) + return output + def get_child_path(self, child_obj): result_idx = None for idx, _child_obj in enumerate(self.children): diff --git a/openpype/settings/entities/list_entity.py b/openpype/settings/entities/list_entity.py index b07441251a..b06f4d7a2e 100644 --- a/openpype/settings/entities/list_entity.py +++ b/openpype/settings/entities/list_entity.py @@ -45,6 +45,24 @@ class ListEntity(EndpointEntity): return True return False + def __getitem__(self, idx): + if not isinstance(idx, int): + idx = int(idx) + return self.children[idx] + + def __setitem__(self, idx, value): + if not isinstance(idx, int): + idx = int(idx) + self.children[idx].set(value) + + def get(self, idx, default=None): + if not isinstance(idx, int): + idx = int(idx) + + if idx < len(self.children): + return self.children[idx] + return default + def index(self, item): if isinstance(item, BaseEntity): for idx, child_entity in enumerate(self.children): diff --git a/openpype/settings/entities/root_entities.py b/openpype/settings/entities/root_entities.py index 00677480e8..4a06d2d591 100644 --- a/openpype/settings/entities/root_entities.py +++ b/openpype/settings/entities/root_entities.py @@ -242,6 +242,14 @@ class RootEntity(BaseItemEntity): """Whan any children has changed.""" self.on_change() + def collect_static_entities_by_path(self): + output = {} + for child_obj in self.non_gui_children.values(): + result = child_obj.collect_static_entities_by_path() + if result: + output.update(result) + return output + def get_child_path(self, child_entity): """Return path of children entity""" for key, _child_entity in self.non_gui_children.items(): diff --git a/openpype/settings/entities/schemas/README.md b/openpype/settings/entities/schemas/README.md index 42a8973f43..2034d4e463 100644 --- a/openpype/settings/entities/schemas/README.md +++ b/openpype/settings/entities/schemas/README.md @@ -195,6 +195,7 @@ - all items in `enum_children` must have at least `key` key which represents value stored under `enum_key` - items can define `label` for UI purposes - most important part is that item can define `children` key where are definitions of it's children (`children` value works the same way as in `dict`) +- to set default value for `enum_key` set it with `enum_default` - entity must have defined `"label"` if is not used as widget - is set as group if any parent is not group - if `"label"` is entetered there which will be shown in GUI @@ -359,6 +360,9 @@ How output of the schema could look like on save: - values are defined under value of key `"enum_items"` as list - each item in list is simple dictionary where value is label and key is value which will be stored - should be possible to enter single dictionary if order of items doesn't matter +- it is possible to set default selected value/s with `default` attribute + - it is recommended to use this option only in single selection mode + - at the end this option is used only when defying default settings value or in dynamic items ``` { @@ -371,7 +375,7 @@ How output of the schema could look like on save: {"ftrackreview": "Add to Ftrack"}, {"delete": "Delete output"}, {"slate-frame": "Add slate frame"}, - {"no-hnadles": "Skip handle frames"} + {"no-handles": "Skip handle frames"} ] } ``` diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json b/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json index 8e6a4b10e4..53c6bf48c0 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json @@ -151,7 +151,7 @@ "type": "dict", "collapsible": true, "key": "MayaSubmitDeadline", - "label": "Submit maya job to deadline", + "label": "Submit Maya job to Deadline", "checkbox_key": "enabled", "children": [ { @@ -213,6 +213,31 @@ "type": "raw-json", "key": "pluginInfo", "label": "Additional PluginInfo data" + }, + { + "type": "list", + "key": "scene_patches", + "label": "Scene patches", + "required_keys": ["name", "regex", "line"], + "object_type": { + "type": "dict", + "children": [ + { + "key": "name", + "label": "Patch name", + "type": "text" + }, { + "key": "regex", + "label": "Patch regex", + "type": "text" + }, { + "key": "line", + "label": "Patch line", + "type": "text" + } + ] + + } } ] }, diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json b/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json index c2a8274313..cc70516c72 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json @@ -14,6 +14,39 @@ "type": "text" } }, + { + "type": "dict", + "collapsible": true, + "checkbox_key": "enabled", + "key": "maya-dirmap", + "label": "Maya Directory Mapping", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "dict", + "key": "paths", + "children": [ + { + "type": "list", + "object_type": "text", + "key": "source-path", + "label": "Source Path" + }, + { + "type": "list", + "object_type": "text", + "key": "destination-path", + "label": "Destination Path" + } + ] + } + ] + }, { "type": "schema", "name": "schema_maya_scriptsmenu" diff --git a/openpype/tools/settings/settings/base.py b/openpype/tools/settings/settings/base.py index eb5f82ab9a..8235cf8642 100644 --- a/openpype/tools/settings/settings/base.py +++ b/openpype/tools/settings/settings/base.py @@ -25,6 +25,38 @@ class BaseWidget(QtWidgets.QWidget): self.label_widget = None self.create_ui() + def scroll_to(self, widget): + self.category_widget.scroll_to(widget) + + def set_path(self, path): + self.category_widget.set_path(path) + + def set_focus(self, scroll_to=False): + """Set focus of a widget. + + Args: + scroll_to(bool): Also scroll to widget in category widget. + """ + if scroll_to: + self.scroll_to(self) + self.setFocus() + + def make_sure_is_visible(self, path, scroll_to): + """Make a widget of entity visible by it's path. + + Args: + path(str): Path to entity. + scroll_to(bool): Should be scrolled to entity. + + Returns: + bool: Entity with path was found. + """ + raise NotImplementedError( + "{} not implemented `make_sure_is_visible`".format( + self.__class__.__name__ + ) + ) + def trigger_hierarchical_style_update(self): self.category_widget.hierarchical_style_update() @@ -277,11 +309,23 @@ class BaseWidget(QtWidgets.QWidget): if to_run: to_run() + def focused_in(self): + if self.entity is not None: + self.set_path(self.entity.path) + def mouseReleaseEvent(self, event): if self.allow_actions and event.button() == QtCore.Qt.RightButton: return self.show_actions_menu() - return super(BaseWidget, self).mouseReleaseEvent(event) + focused_in = False + if event.button() == QtCore.Qt.LeftButton: + focused_in = True + self.focused_in() + + result = super(BaseWidget, self).mouseReleaseEvent(event) + if focused_in and not event.isAccepted(): + event.accept() + return result class InputWidget(BaseWidget): @@ -337,6 +381,14 @@ class InputWidget(BaseWidget): ) ) + def make_sure_is_visible(self, path, scroll_to): + if path: + entity_path = self.entity.path + if entity_path == path: + self.set_focus(scroll_to) + return True + return False + def update_style(self): has_unsaved_changes = self.entity.has_unsaved_changes if not has_unsaved_changes and self.entity.group_item: @@ -422,11 +474,20 @@ class GUIWidget(BaseWidget): layout.addWidget(splitter_item) def set_entity_value(self): - return + pass def hierarchical_style_update(self): pass + def make_sure_is_visible(self, *args, **kwargs): + return False + + def focused_in(self): + pass + + def set_path(self, *args, **kwargs): + pass + def get_invalid(self): return [] diff --git a/openpype/tools/settings/settings/breadcrumbs_widget.py b/openpype/tools/settings/settings/breadcrumbs_widget.py new file mode 100644 index 0000000000..b625a7bb07 --- /dev/null +++ b/openpype/tools/settings/settings/breadcrumbs_widget.py @@ -0,0 +1,492 @@ +from Qt import QtWidgets, QtGui, QtCore + +PREFIX_ROLE = QtCore.Qt.UserRole + 1 +LAST_SEGMENT_ROLE = QtCore.Qt.UserRole + 2 + + +class BreadcrumbItem(QtGui.QStandardItem): + def __init__(self, *args, **kwargs): + self._display_value = None + self._edit_value = None + super(BreadcrumbItem, self).__init__(*args, **kwargs) + + def data(self, role=None): + if role == QtCore.Qt.DisplayRole: + return self._display_value + + if role == QtCore.Qt.EditRole: + return self._edit_value + + if role is None: + args = tuple() + else: + args = (role, ) + return super(BreadcrumbItem, self).data(*args) + + def setData(self, value, role): + if role == QtCore.Qt.DisplayRole: + self._display_value = value + return True + + if role == QtCore.Qt.EditRole: + self._edit_value = value + return True + + if role is None: + args = (value, ) + else: + args = (value, role) + return super(BreadcrumbItem, self).setData(*args) + + +class BreadcrumbsModel(QtGui.QStandardItemModel): + def __init__(self): + super(BreadcrumbsModel, self).__init__() + self.current_path = "" + + self.reset() + + def reset(self): + return + + +class SettingsBreadcrumbs(BreadcrumbsModel): + def __init__(self): + self.entity = None + + self.entities_by_path = {} + self.dynamic_paths = set() + + super(SettingsBreadcrumbs, self).__init__() + + def set_entity(self, entity): + self.entities_by_path = {} + self.dynamic_paths = set() + self.entity = entity + self.reset() + + def has_children(self, path): + for key in self.entities_by_path.keys(): + if key.startswith(path): + return True + return False + + def is_valid_path(self, path): + if not path: + return True + + path_items = path.split("/") + try: + entity = self.entity + for item in path_items: + entity = entity[item] + except Exception: + return False + return True + + +class SystemSettingsBreadcrumbs(SettingsBreadcrumbs): + def reset(self): + root_item = self.invisibleRootItem() + rows = root_item.rowCount() + if rows > 0: + root_item.removeRows(0, rows) + + if self.entity is None: + return + + entities_by_path = self.entity.collect_static_entities_by_path() + self.entities_by_path = entities_by_path + items = [] + for path in entities_by_path.keys(): + if not path: + continue + path_items = path.split("/") + value = path + label = path_items.pop(-1) + prefix = "/".join(path_items) + if prefix: + prefix += "/" + + item = QtGui.QStandardItem(value) + item.setData(label, LAST_SEGMENT_ROLE) + item.setData(prefix, PREFIX_ROLE) + + items.append(item) + + root_item.appendRows(items) + + +class ProjectSettingsBreadcrumbs(SettingsBreadcrumbs): + def reset(self): + root_item = self.invisibleRootItem() + rows = root_item.rowCount() + if rows > 0: + root_item.removeRows(0, rows) + + if self.entity is None: + return + + entities_by_path = self.entity.collect_static_entities_by_path() + self.entities_by_path = entities_by_path + items = [] + for path in entities_by_path.keys(): + if not path: + continue + path_items = path.split("/") + value = path + label = path_items.pop(-1) + prefix = "/".join(path_items) + if prefix: + prefix += "/" + + item = QtGui.QStandardItem(value) + item.setData(label, LAST_SEGMENT_ROLE) + item.setData(prefix, PREFIX_ROLE) + + items.append(item) + + root_item.appendRows(items) + + +class BreadcrumbsProxy(QtCore.QSortFilterProxyModel): + def __init__(self, *args, **kwargs): + super(BreadcrumbsProxy, self).__init__(*args, **kwargs) + + self._current_path = "" + + def set_path_prefix(self, prefix): + path = prefix + if not prefix.endswith("/"): + path_items = path.split("/") + if len(path_items) == 1: + path = "" + else: + path_items.pop(-1) + path = "/".join(path_items) + "/" + + if path == self._current_path: + return + + self._current_path = prefix + + self.invalidateFilter() + + def filterAcceptsRow(self, row, parent): + index = self.sourceModel().index(row, 0, parent) + prefix_path = index.data(PREFIX_ROLE) + return prefix_path == self._current_path + + +class BreadcrumbsHintMenu(QtWidgets.QMenu): + def __init__(self, model, path_prefix, parent): + super(BreadcrumbsHintMenu, self).__init__(parent) + + self._path_prefix = path_prefix + self._model = model + + def showEvent(self, event): + self.clear() + + self._model.set_path_prefix(self._path_prefix) + + row_count = self._model.rowCount() + if row_count == 0: + action = self.addAction("* Nothing") + action.setData(".") + else: + for row in range(self._model.rowCount()): + index = self._model.index(row, 0) + label = index.data(LAST_SEGMENT_ROLE) + value = index.data(QtCore.Qt.EditRole) + action = self.addAction(label) + action.setData(value) + + super(BreadcrumbsHintMenu, self).showEvent(event) + + +class ClickableWidget(QtWidgets.QWidget): + clicked = QtCore.Signal() + + def mouseReleaseEvent(self, event): + if event.button() == QtCore.Qt.LeftButton: + self.clicked.emit() + super(ClickableWidget, self).mouseReleaseEvent(event) + + +class BreadcrumbsPathInput(QtWidgets.QLineEdit): + cancelled = QtCore.Signal() + confirmed = QtCore.Signal() + + def __init__(self, model, proxy_model, parent): + super(BreadcrumbsPathInput, self).__init__(parent) + + self.setObjectName("BreadcrumbsPathInput") + + self.setFrame(False) + + completer = QtWidgets.QCompleter(self) + completer.setCaseSensitivity(QtCore.Qt.CaseInsensitive) + completer.setModel(proxy_model) + + popup = completer.popup() + popup.setUniformItemSizes(True) + popup.setLayoutMode(QtWidgets.QListView.Batched) + + self.setCompleter(completer) + + completer.activated.connect(self._on_completer_activated) + self.textEdited.connect(self._on_text_change) + + self._completer = completer + self._model = model + self._proxy_model = proxy_model + + self._context_menu_visible = False + + def set_model(self, model): + self._model = model + + def event(self, event): + if ( + event.type() == QtCore.QEvent.KeyPress + and event.key() == QtCore.Qt.Key_Tab + ): + if self._model: + find_value = self.text() + "/" + if self._model.has_children(find_value): + self.insert("/") + else: + self._completer.popup().hide() + event.accept() + return True + + return super(BreadcrumbsPathInput, self).event(event) + + def keyPressEvent(self, event): + if event.key() == QtCore.Qt.Key_Escape: + self.cancelled.emit() + return + + if event.key() in (QtCore.Qt.Key_Return, QtCore.Qt.Key_Enter): + self.confirmed.emit() + return + + super(BreadcrumbsPathInput, self).keyPressEvent(event) + + def focusOutEvent(self, event): + if not self._context_menu_visible: + self.cancelled.emit() + + self._context_menu_visible = False + super(BreadcrumbsPathInput, self).focusOutEvent(event) + + def contextMenuEvent(self, event): + self._context_menu_visible = True + super(BreadcrumbsPathInput, self).contextMenuEvent(event) + + def _on_completer_activated(self, path): + self.confirmed.emit() + + def _on_text_change(self, path): + self._proxy_model.set_path_prefix(path) + + +class BreadcrumbsButton(QtWidgets.QToolButton): + path_selected = QtCore.Signal(str) + + def __init__(self, path, model, parent): + super(BreadcrumbsButton, self).__init__(parent) + + self.setObjectName("BreadcrumbsButton") + + path_prefix = path + if path: + path_prefix += "/" + + self.setAutoRaise(True) + self.setPopupMode(QtWidgets.QToolButton.MenuButtonPopup) + + self.setMouseTracking(True) + + if path: + self.setText(path.split("/")[-1]) + else: + self.setProperty("empty", "1") + + menu = BreadcrumbsHintMenu(model, path_prefix, self) + + self.setMenu(menu) + + # fixed size breadcrumbs + self.setMinimumSize(self.minimumSizeHint()) + size_policy = self.sizePolicy() + size_policy.setVerticalPolicy(size_policy.Minimum) + self.setSizePolicy(size_policy) + + menu.triggered.connect(self._on_menu_click) + self.clicked.connect(self._on_click) + + self._path = path + self._path_prefix = path_prefix + self._model = model + self._menu = menu + + def _on_click(self): + self.path_selected.emit(self._path) + + def _on_menu_click(self, action): + item = action.data() + self.path_selected.emit(item) + + +class BreadcrumbsAddressBar(QtWidgets.QFrame): + "Windows Explorer-like address bar" + path_changed = QtCore.Signal(str) + path_edited = QtCore.Signal(str) + + def __init__(self, parent=None): + super(BreadcrumbsAddressBar, self).__init__(parent) + + self.setAutoFillBackground(True) + self.setFrameShape(self.StyledPanel) + + # Edit presented path textually + proxy_model = BreadcrumbsProxy() + path_input = BreadcrumbsPathInput(None, proxy_model, self) + path_input.setVisible(False) + + path_input.cancelled.connect(self._on_input_cancel) + path_input.confirmed.connect(self._on_input_confirm) + + # Container for `crumbs_panel` + crumbs_container = QtWidgets.QWidget(self) + + # Container for breadcrumbs + crumbs_panel = QtWidgets.QWidget(crumbs_container) + crumbs_panel.setObjectName("BreadcrumbsPanel") + + crumbs_layout = QtWidgets.QHBoxLayout() + crumbs_layout.setContentsMargins(0, 0, 0, 0) + crumbs_layout.setSpacing(0) + + crumbs_cont_layout = QtWidgets.QHBoxLayout(crumbs_container) + crumbs_cont_layout.setContentsMargins(0, 0, 0, 0) + crumbs_cont_layout.setSpacing(0) + crumbs_cont_layout.addWidget(crumbs_panel) + + # Clicking on empty space to the right puts the bar into edit mode + switch_space = ClickableWidget(self) + + crumb_panel_layout = QtWidgets.QHBoxLayout(crumbs_panel) + crumb_panel_layout.setContentsMargins(0, 0, 0, 0) + crumb_panel_layout.setSpacing(0) + crumb_panel_layout.addLayout(crumbs_layout, 0) + crumb_panel_layout.addWidget(switch_space, 1) + + switch_space.clicked.connect(self.switch_space_mouse_up) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(path_input) + layout.addWidget(crumbs_container) + + self.setMaximumHeight(path_input.height()) + + self.crumbs_layout = crumbs_layout + self.crumbs_panel = crumbs_panel + self.switch_space = switch_space + self.path_input = path_input + self.crumbs_container = crumbs_container + + self._model = None + self._proxy_model = proxy_model + + self._current_path = None + + def set_model(self, model): + self._model = model + self.path_input.set_model(model) + self._proxy_model.setSourceModel(model) + + def _on_input_confirm(self): + self.change_path(self.path_input.text()) + + def _on_input_cancel(self): + self._cancel_edit() + + def _clear_crumbs(self): + while self.crumbs_layout.count(): + widget = self.crumbs_layout.takeAt(0).widget() + if widget: + widget.deleteLater() + + def _insert_crumb(self, path): + btn = BreadcrumbsButton(path, self._proxy_model, self.crumbs_panel) + + self.crumbs_layout.insertWidget(0, btn) + + btn.path_selected.connect(self._on_crumb_clicked) + + def _on_crumb_clicked(self, path): + "Breadcrumb was clicked" + self.change_path(path) + + def change_path(self, path): + if self._model and not self._model.is_valid_path(path): + self._show_address_field() + else: + self.set_path(path) + self.path_edited.emit(path) + + def set_path(self, path): + if path is None or path == ".": + path = self._current_path + + # exit edit mode + self._cancel_edit() + + self._clear_crumbs() + self._current_path = path + self.path_input.setText(path) + path_items = [ + item + for item in path.split("/") + if item + ] + while path_items: + item = "/".join(path_items) + self._insert_crumb(item) + path_items.pop(-1) + self._insert_crumb("") + + self.path_changed.emit(self._current_path) + + def _cancel_edit(self): + "Set edit line text back to current path and switch to view mode" + # revert path + self.path_input.setText(self.path()) + # switch back to breadcrumbs view + self._show_address_field(False) + + def path(self): + "Get path displayed in this BreadcrumbsAddressBar" + return self._current_path + + def switch_space_mouse_up(self): + "EVENT: switch_space mouse clicked" + self._show_address_field(True) + + def _show_address_field(self, show=True): + "Show text address field" + self.crumbs_container.setVisible(not show) + self.path_input.setVisible(show) + if show: + self.path_input.setFocus() + self.path_input.selectAll() + + def minimumSizeHint(self): + result = super(BreadcrumbsAddressBar, self).minimumSizeHint() + result.setHeight(self.path_input.minimumSizeHint().height()) + return result diff --git a/openpype/tools/settings/settings/categories.py b/openpype/tools/settings/settings/categories.py index 8be3eddfa8..d1babd7fdb 100644 --- a/openpype/tools/settings/settings/categories.py +++ b/openpype/tools/settings/settings/categories.py @@ -31,6 +31,11 @@ from openpype.settings.entities import ( from openpype.settings import SaveWarningExc from .widgets import ProjectListWidget +from .breadcrumbs_widget import ( + BreadcrumbsAddressBar, + SystemSettingsBreadcrumbs, + ProjectSettingsBreadcrumbs +) from .base import GUIWidget from .list_item_widget import ListWidget @@ -175,6 +180,16 @@ class SettingsCategoryWidget(QtWidgets.QWidget): scroll_widget = QtWidgets.QScrollArea(self) scroll_widget.setObjectName("GroupWidget") content_widget = QtWidgets.QWidget(scroll_widget) + + breadcrumbs_label = QtWidgets.QLabel("Path:", content_widget) + breadcrumbs_widget = BreadcrumbsAddressBar(content_widget) + + breadcrumbs_layout = QtWidgets.QHBoxLayout() + breadcrumbs_layout.setContentsMargins(5, 5, 5, 5) + breadcrumbs_layout.setSpacing(5) + breadcrumbs_layout.addWidget(breadcrumbs_label) + breadcrumbs_layout.addWidget(breadcrumbs_widget) + content_layout = QtWidgets.QVBoxLayout(content_widget) content_layout.setContentsMargins(3, 3, 3, 3) content_layout.setSpacing(5) @@ -183,40 +198,43 @@ class SettingsCategoryWidget(QtWidgets.QWidget): scroll_widget.setWidgetResizable(True) scroll_widget.setWidget(content_widget) - configurations_widget = QtWidgets.QWidget(self) - - footer_widget = QtWidgets.QWidget(configurations_widget) - footer_layout = QtWidgets.QHBoxLayout(footer_widget) - refresh_icon = qtawesome.icon("fa.refresh", color="white") - refresh_btn = QtWidgets.QPushButton(footer_widget) + refresh_btn = QtWidgets.QPushButton(self) refresh_btn.setIcon(refresh_icon) - footer_layout.addWidget(refresh_btn, 0) - + footer_layout = QtWidgets.QHBoxLayout() if self.user_role == "developer": self._add_developer_ui(footer_layout) - save_btn = QtWidgets.QPushButton("Save", footer_widget) - require_restart_label = QtWidgets.QLabel(footer_widget) + save_btn = QtWidgets.QPushButton("Save", self) + require_restart_label = QtWidgets.QLabel(self) require_restart_label.setAlignment(QtCore.Qt.AlignCenter) + + footer_layout.addWidget(refresh_btn, 0) footer_layout.addWidget(require_restart_label, 1) footer_layout.addWidget(save_btn, 0) - configurations_layout = QtWidgets.QVBoxLayout(configurations_widget) + configurations_layout = QtWidgets.QVBoxLayout() configurations_layout.setContentsMargins(0, 0, 0, 0) configurations_layout.setSpacing(0) configurations_layout.addWidget(scroll_widget, 1) - configurations_layout.addWidget(footer_widget, 0) + configurations_layout.addLayout(footer_layout, 0) - main_layout = QtWidgets.QHBoxLayout(self) + conf_wrapper_layout = QtWidgets.QHBoxLayout() + conf_wrapper_layout.setContentsMargins(0, 0, 0, 0) + conf_wrapper_layout.setSpacing(0) + conf_wrapper_layout.addLayout(configurations_layout, 1) + + main_layout = QtWidgets.QVBoxLayout(self) main_layout.setContentsMargins(0, 0, 0, 0) main_layout.setSpacing(0) - main_layout.addWidget(configurations_widget, 1) + main_layout.addLayout(breadcrumbs_layout, 0) + main_layout.addLayout(conf_wrapper_layout, 1) save_btn.clicked.connect(self._save) refresh_btn.clicked.connect(self._on_refresh) + breadcrumbs_widget.path_edited.connect(self._on_path_edit) self.save_btn = save_btn self.refresh_btn = refresh_btn @@ -224,7 +242,9 @@ class SettingsCategoryWidget(QtWidgets.QWidget): self.scroll_widget = scroll_widget self.content_layout = content_layout self.content_widget = content_widget - self.configurations_widget = configurations_widget + self.breadcrumbs_widget = breadcrumbs_widget + self.breadcrumbs_model = None + self.conf_wrapper_layout = conf_wrapper_layout self.main_layout = main_layout self.ui_tweaks() @@ -232,6 +252,23 @@ class SettingsCategoryWidget(QtWidgets.QWidget): def ui_tweaks(self): return + def _on_path_edit(self, path): + for input_field in self.input_fields: + if input_field.make_sure_is_visible(path, True): + break + + def scroll_to(self, widget): + if widget: + # Process events which happened before ensurence + # - that is because some widgets could be not visible before + # this method was called and have incorrect size + QtWidgets.QApplication.processEvents() + # Scroll to widget + self.scroll_widget.ensureWidgetVisible(widget) + + def set_path(self, path): + self.breadcrumbs_widget.set_path(path) + def _add_developer_ui(self, footer_layout): modify_defaults_widget = QtWidgets.QWidget() modify_defaults_checkbox = QtWidgets.QCheckBox(modify_defaults_widget) @@ -427,10 +464,19 @@ class SettingsCategoryWidget(QtWidgets.QWidget): def _on_reset_crash(self): self.save_btn.setEnabled(False) + if self.breadcrumbs_model is not None: + self.breadcrumbs_model.set_entity(None) + def _on_reset_success(self): if not self.save_btn.isEnabled(): self.save_btn.setEnabled(True) + if self.breadcrumbs_model is not None: + path = self.breadcrumbs_widget.path() + self.breadcrumbs_widget.set_path("") + self.breadcrumbs_model.set_entity(self.entity) + self.breadcrumbs_widget.change_path(path) + def add_children_gui(self): for child_obj in self.entity.children: item = self.create_ui_for_entity(self, child_obj, self) @@ -521,6 +567,10 @@ class SystemWidget(SettingsCategoryWidget): self.modify_defaults_checkbox.setChecked(True) self.modify_defaults_checkbox.setEnabled(False) + def ui_tweaks(self): + self.breadcrumbs_model = SystemSettingsBreadcrumbs() + self.breadcrumbs_widget.set_model(self.breadcrumbs_model) + def _on_modify_defaults(self): if self.modify_defaults_checkbox.isChecked(): if not self.entity.is_in_defaults_state(): @@ -535,9 +585,12 @@ class ProjectWidget(SettingsCategoryWidget): self.project_name = None def ui_tweaks(self): + self.breadcrumbs_model = ProjectSettingsBreadcrumbs() + self.breadcrumbs_widget.set_model(self.breadcrumbs_model) + project_list_widget = ProjectListWidget(self) - self.main_layout.insertWidget(0, project_list_widget, 0) + self.conf_wrapper_layout.insertWidget(0, project_list_widget, 0) project_list_widget.project_changed.connect(self._on_project_change) diff --git a/openpype/tools/settings/settings/dict_conditional.py b/openpype/tools/settings/settings/dict_conditional.py index 31a4fa9fab..3e3270cac9 100644 --- a/openpype/tools/settings/settings/dict_conditional.py +++ b/openpype/tools/settings/settings/dict_conditional.py @@ -213,6 +213,26 @@ class DictConditionalWidget(BaseWidget): else: body_widget.hide_toolbox(hide_content=False) + def make_sure_is_visible(self, path, scroll_to): + if not path: + return False + + entity_path = self.entity.path + if entity_path == path: + self.set_focus(scroll_to) + return True + + if not path.startswith(entity_path): + return False + + if self.body_widget and not self.body_widget.is_expanded(): + self.body_widget.toggle_content(True) + + for input_field in self.input_fields: + if input_field.make_sure_is_visible(path, scroll_to): + return True + return False + def add_widget_to_layout(self, widget, label=None): if not widget.entity: map_id = widget.id diff --git a/openpype/tools/settings/settings/dict_mutable_widget.py b/openpype/tools/settings/settings/dict_mutable_widget.py index df6525e86a..ba86fe82dd 100644 --- a/openpype/tools/settings/settings/dict_mutable_widget.py +++ b/openpype/tools/settings/settings/dict_mutable_widget.py @@ -1,12 +1,11 @@ from uuid import uuid4 -from Qt import QtWidgets, QtCore +from Qt import QtWidgets, QtCore, QtGui from .base import BaseWidget from .widgets import ( ExpandingWidget, - IconButton, - SpacerWidget + IconButton ) from openpype.tools.settings import ( BTN_FIXED_SIZE, @@ -15,6 +14,69 @@ from openpype.tools.settings import ( from openpype.settings.constants import KEY_REGEX +KEY_INPUT_TOOLTIP = ( + "Keys can't be duplicated and may contain alphabetical character (a-Z)" + "\nnumerical characters (0-9) dash (\"-\") or underscore (\"_\")." +) + + +class PaintHelper: + cached_icons = {} + + @classmethod + def _draw_image(cls, width, height, brush): + image = QtGui.QPixmap(width, height) + image.fill(QtCore.Qt.transparent) + + icon_path_stroker = QtGui.QPainterPathStroker() + icon_path_stroker.setCapStyle(QtCore.Qt.RoundCap) + icon_path_stroker.setJoinStyle(QtCore.Qt.RoundJoin) + icon_path_stroker.setWidth(height / 5) + + painter = QtGui.QPainter(image) + painter.setPen(QtCore.Qt.transparent) + painter.setBrush(brush) + rect = QtCore.QRect(0, 0, image.width(), image.height()) + fifteenth = rect.height() / 15 + # Left point + p1 = QtCore.QPoint( + rect.x() + (5 * fifteenth), + rect.y() + (9 * fifteenth) + ) + # Middle bottom point + p2 = QtCore.QPoint( + rect.center().x(), + rect.y() + (11 * fifteenth) + ) + # Top right point + p3 = QtCore.QPoint( + rect.x() + (10 * fifteenth), + rect.y() + (5 * fifteenth) + ) + + path = QtGui.QPainterPath(p1) + path.lineTo(p2) + path.lineTo(p3) + + stroked_path = icon_path_stroker.createStroke(path) + painter.drawPath(stroked_path) + + painter.end() + + return image + + @classmethod + def get_confirm_icon(cls, width, height): + key = "{}x{}-confirm_image".format(width, height) + icon = cls.cached_icons.get(key) + + if icon is None: + image = cls._draw_image(width, height, QtCore.Qt.white) + icon = QtGui.QIcon(image) + cls.cached_icons[key] = icon + return icon + + def create_add_btn(parent): add_btn = QtWidgets.QPushButton("+", parent) add_btn.setFocusPolicy(QtCore.Qt.ClickFocus) @@ -31,6 +93,19 @@ def create_remove_btn(parent): return remove_btn +def create_confirm_btn(parent): + confirm_btn = QtWidgets.QPushButton(parent) + + icon = PaintHelper.get_confirm_icon( + BTN_FIXED_SIZE, BTN_FIXED_SIZE + ) + confirm_btn.setIcon(icon) + confirm_btn.setFocusPolicy(QtCore.Qt.ClickFocus) + confirm_btn.setProperty("btn-type", "tool-item") + confirm_btn.setFixedSize(BTN_FIXED_SIZE, BTN_FIXED_SIZE) + return confirm_btn + + class ModifiableDictEmptyItem(QtWidgets.QWidget): def __init__(self, entity_widget, store_as_list, parent): super(ModifiableDictEmptyItem, self).__init__(parent) @@ -42,6 +117,8 @@ class ModifiableDictEmptyItem(QtWidgets.QWidget): self.is_duplicated = False self.key_is_valid = store_as_list + self.confirm_btn = None + if self.collapsible_key: self.create_collapsible_ui() else: @@ -61,7 +138,6 @@ class ModifiableDictEmptyItem(QtWidgets.QWidget): def create_addible_ui(self): add_btn = create_add_btn(self) remove_btn = create_remove_btn(self) - spacer_widget = SpacerWidget(self) remove_btn.setEnabled(False) @@ -70,13 +146,12 @@ class ModifiableDictEmptyItem(QtWidgets.QWidget): layout.setSpacing(3) layout.addWidget(add_btn, 0) layout.addWidget(remove_btn, 0) - layout.addWidget(spacer_widget, 1) + layout.addStretch(1) add_btn.clicked.connect(self._on_add_clicked) self.add_btn = add_btn self.remove_btn = remove_btn - self.spacer_widget = spacer_widget def _on_focus_lose(self): if self.key_input.hasFocus() or self.key_label_input.hasFocus(): @@ -111,7 +186,16 @@ class ModifiableDictEmptyItem(QtWidgets.QWidget): self.is_duplicated = self.entity_widget.is_key_duplicated(key) key_input_state = "" # Collapsible key and empty key are not invalid - if self.collapsible_key and self.key_input.text() == "": + key_value = self.key_input.text() + if self.confirm_btn is not None: + conf_disabled = ( + key_value == "" + or not self.key_is_valid + or self.is_duplicated + ) + self.confirm_btn.setEnabled(not conf_disabled) + + if self.collapsible_key and key_value == "": pass elif self.is_duplicated or not self.key_is_valid: key_input_state = "invalid" @@ -124,6 +208,7 @@ class ModifiableDictEmptyItem(QtWidgets.QWidget): def create_collapsible_ui(self): key_input = QtWidgets.QLineEdit(self) key_input.setObjectName("DictKey") + key_input.setToolTip(KEY_INPUT_TOOLTIP) key_label_input = QtWidgets.QLineEdit(self) @@ -141,11 +226,15 @@ class ModifiableDictEmptyItem(QtWidgets.QWidget): key_input_label_widget = QtWidgets.QLabel("Key:", self) key_label_input_label_widget = QtWidgets.QLabel("Label:", self) + confirm_btn = create_confirm_btn(self) + confirm_btn.setEnabled(False) + wrapper_widget = ExpandingWidget("", self) wrapper_widget.add_widget_after_label(key_input_label_widget) wrapper_widget.add_widget_after_label(key_input) wrapper_widget.add_widget_after_label(key_label_input_label_widget) wrapper_widget.add_widget_after_label(key_label_input) + wrapper_widget.add_widget_after_label(confirm_btn) wrapper_widget.hide_toolbox() layout = QtWidgets.QVBoxLayout(self) @@ -157,9 +246,12 @@ class ModifiableDictEmptyItem(QtWidgets.QWidget): key_input.returnPressed.connect(self._on_enter_press) key_label_input.returnPressed.connect(self._on_enter_press) + confirm_btn.clicked.connect(self._on_enter_press) + self.key_input = key_input self.key_label_input = key_label_input self.wrapper_widget = wrapper_widget + self.confirm_btn = confirm_btn class ModifiableDictItem(QtWidgets.QWidget): @@ -190,10 +282,14 @@ class ModifiableDictItem(QtWidgets.QWidget): self.key_label_input = None + self.confirm_btn = None + if collapsible_key: self.create_collapsible_ui() else: self.create_addible_ui() + + self.key_input.setToolTip(KEY_INPUT_TOOLTIP) self.update_style() @property @@ -277,6 +373,9 @@ class ModifiableDictItem(QtWidgets.QWidget): edit_btn.setProperty("btn-type", "tool-item-icon") edit_btn.setFixedHeight(BTN_FIXED_SIZE) + confirm_btn = create_confirm_btn(self) + confirm_btn.setVisible(False) + remove_btn = create_remove_btn(self) key_input_label_widget = QtWidgets.QLabel("Key:") @@ -286,6 +385,7 @@ class ModifiableDictItem(QtWidgets.QWidget): wrapper_widget.add_widget_after_label(key_input) wrapper_widget.add_widget_after_label(key_label_input_label_widget) wrapper_widget.add_widget_after_label(key_label_input) + wrapper_widget.add_widget_after_label(confirm_btn) wrapper_widget.add_widget_after_label(remove_btn) key_input.textChanged.connect(self._on_key_change) @@ -295,6 +395,7 @@ class ModifiableDictItem(QtWidgets.QWidget): key_label_input.returnPressed.connect(self._on_enter_press) edit_btn.clicked.connect(self.on_edit_pressed) + confirm_btn.clicked.connect(self._on_enter_press) remove_btn.clicked.connect(self.on_remove_clicked) # Hide edit inputs @@ -310,6 +411,7 @@ class ModifiableDictItem(QtWidgets.QWidget): self.key_label_input_label_widget = key_label_input_label_widget self.wrapper_widget = wrapper_widget self.edit_btn = edit_btn + self.confirm_btn = confirm_btn self.remove_btn = remove_btn self.content_widget = content_widget @@ -319,6 +421,9 @@ class ModifiableDictItem(QtWidgets.QWidget): self.category_widget, self.entity, self ) + def make_sure_is_visible(self, *args, **kwargs): + return self.input_field.make_sure_is_visible(*args, **kwargs) + def get_style_state(self): if self.is_invalid: return "invalid" @@ -415,6 +520,14 @@ class ModifiableDictItem(QtWidgets.QWidget): self.temp_key, key, self ) self.temp_key = key + if self.confirm_btn is not None: + conf_disabled = ( + key == "" + or not self.key_is_valid + or is_key_duplicated + ) + self.confirm_btn.setEnabled(not conf_disabled) + if is_key_duplicated or not self.key_is_valid: return @@ -434,7 +547,7 @@ class ModifiableDictItem(QtWidgets.QWidget): key_value = self.key_input.text() key_label_value = self.key_label_input.text() if key_label_value: - label = "{} ({})".format(key_label_value, key_value) + label = "{} ({})".format(key_value, key_label_value) else: label = key_value self.wrapper_widget.label_widget.setText(label) @@ -457,6 +570,7 @@ class ModifiableDictItem(QtWidgets.QWidget): self.key_input.setVisible(enabled) self.key_input_label_widget.setVisible(enabled) self.key_label_input.setVisible(enabled) + self.confirm_btn.setVisible(enabled) if not self.is_required: self.remove_btn.setVisible(enabled) if enabled: @@ -681,10 +795,6 @@ class DictMutableKeysWidget(BaseWidget): def remove_key(self, widget): key = self.entity.get_child_key(widget.entity) self.entity.pop(key) - # Poping of key from entity should remove the entity and input field. - # this is kept for testing purposes. - if widget in self.input_fields: - self.remove_row(widget) def change_key(self, new_key, widget): if not new_key or widget.is_key_duplicated: @@ -751,6 +861,11 @@ class DictMutableKeysWidget(BaseWidget): return input_field def remove_row(self, widget): + if widget.is_key_duplicated: + new_key = widget.uuid_key + if new_key is None: + new_key = str(uuid4()) + self.validate_key_duplication(widget.temp_key, new_key, widget) self.input_fields.remove(widget) self.content_layout.removeWidget(widget) widget.deleteLater() @@ -834,7 +949,10 @@ class DictMutableKeysWidget(BaseWidget): _input_field.set_entity_value() else: - if input_field.key_value() != key: + if ( + not input_field.is_key_duplicated + and input_field.key_value() != key + ): changed = True input_field.set_key(key) @@ -846,6 +964,26 @@ class DictMutableKeysWidget(BaseWidget): if changed: self.on_shuffle() + def make_sure_is_visible(self, path, scroll_to): + if not path: + return False + + entity_path = self.entity.path + if entity_path == path: + self.set_focus(scroll_to) + return True + + if not path.startswith(entity_path): + return False + + if self.body_widget and not self.body_widget.is_expanded(): + self.body_widget.toggle_content(True) + + for input_field in self.input_fields: + if input_field.make_sure_is_visible(path, scroll_to): + return True + return False + def set_entity_value(self): while self.input_fields: self.remove_row(self.input_fields[0]) diff --git a/openpype/tools/settings/settings/item_widgets.py b/openpype/tools/settings/settings/item_widgets.py index 82afbb0a13..d29fa6f42b 100644 --- a/openpype/tools/settings/settings/item_widgets.py +++ b/openpype/tools/settings/settings/item_widgets.py @@ -6,8 +6,10 @@ from .widgets import ( ExpandingWidget, NumberSpinBox, GridLabelWidget, - ComboBox, - NiceCheckbox + SettingsComboBox, + NiceCheckbox, + SettingsPlainTextEdit, + SettingsLineEdit ) from .multiselection_combobox import MultiSelectionComboBox from .wrapper_widgets import ( @@ -46,6 +48,7 @@ class DictImmutableKeysWidget(BaseWidget): self._ui_item_base() label = self.entity.label + self._direct_children_widgets = [] self._parent_widget_by_entity_id = {} self._added_wrapper_ids = set() self._prepare_entity_layouts( @@ -154,9 +157,41 @@ class DictImmutableKeysWidget(BaseWidget): else: body_widget.hide_toolbox(hide_content=False) + def make_sure_is_visible(self, path, scroll_to): + if not path: + return False + + entity_path = self.entity.path + if entity_path == path: + self.set_focus(scroll_to) + return True + + if not path.startswith(entity_path): + return False + + is_checkbox_child = False + changed = False + for direct_child in self._direct_children_widgets: + if direct_child.make_sure_is_visible(path, scroll_to): + changed = True + if direct_child.entity is self.checkbox_child: + is_checkbox_child = True + break + + # Change scroll to this widget + if is_checkbox_child: + self.scroll_to(self) + + elif self.body_widget and not self.body_widget.is_expanded(): + # Expand widget if is callapsible + self.body_widget.toggle_content(True) + + return changed + def add_widget_to_layout(self, widget, label=None): if self.checkbox_child and widget.entity is self.checkbox_child: self.body_widget.add_widget_before_label(widget) + self._direct_children_widgets.append(widget) return if not widget.entity: @@ -172,6 +207,8 @@ class DictImmutableKeysWidget(BaseWidget): self._added_wrapper_ids.add(wrapper.id) return + self._direct_children_widgets.append(widget) + row = self.content_layout.rowCount() if not label or isinstance(widget, WrapperWidget): self.content_layout.addWidget(widget, row, 0, 1, 2) @@ -270,11 +307,8 @@ class BoolWidget(InputWidget): height=checkbox_height, parent=self.content_widget ) - spacer = QtWidgets.QWidget(self.content_widget) - spacer.setAttribute(QtCore.Qt.WA_TranslucentBackground) - self.content_layout.addWidget(self.input_field, 0) - self.content_layout.addWidget(spacer, 1) + self.content_layout.addStretch(1) self.setFocusProxy(self.input_field) @@ -297,9 +331,9 @@ class TextWidget(InputWidget): def _add_inputs_to_layout(self): multiline = self.entity.multiline if multiline: - self.input_field = QtWidgets.QPlainTextEdit(self.content_widget) + self.input_field = SettingsPlainTextEdit(self.content_widget) else: - self.input_field = QtWidgets.QLineEdit(self.content_widget) + self.input_field = SettingsLineEdit(self.content_widget) placeholder_text = self.entity.placeholder_text if placeholder_text: @@ -313,8 +347,12 @@ class TextWidget(InputWidget): self.content_layout.addWidget(self.input_field, 1, **layout_kwargs) + self.input_field.focused_in.connect(self._on_input_focus) self.input_field.textChanged.connect(self._on_value_change) + def _on_input_focus(self): + self.focused_in() + def _on_entity_change(self): if self.entity.value != self.input_value(): self.set_entity_value() @@ -352,6 +390,10 @@ class NumberWidget(InputWidget): self.content_layout.addWidget(self.input_field, 1) self.input_field.valueChanged.connect(self._on_value_change) + self.input_field.focused_in.connect(self._on_input_focus) + + def _on_input_focus(self): + self.focused_in() def _on_entity_change(self): if self.entity.value != self.input_field.value(): @@ -366,7 +408,7 @@ class NumberWidget(InputWidget): self.entity.set(self.input_field.value()) -class RawJsonInput(QtWidgets.QPlainTextEdit): +class RawJsonInput(SettingsPlainTextEdit): tab_length = 4 def __init__(self, valid_type, *args, **kwargs): @@ -428,15 +470,18 @@ class RawJsonWidget(InputWidget): QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.MinimumExpanding ) - self.setFocusProxy(self.input_field) self.content_layout.addWidget( self.input_field, 1, alignment=QtCore.Qt.AlignTop ) + self.input_field.focused_in.connect(self._on_input_focus) self.input_field.textChanged.connect(self._on_value_change) + def _on_input_focus(self): + self.focused_in() + def set_entity_value(self): self.input_field.set_value(self.entity.value) self._is_invalid = self.input_field.has_invalid_value() @@ -470,7 +515,7 @@ class EnumeratorWidget(InputWidget): ) else: - self.input_field = ComboBox(self.content_widget) + self.input_field = SettingsComboBox(self.content_widget) for enum_item in self.entity.enum_items: for value, label in enum_item.items(): @@ -480,8 +525,12 @@ class EnumeratorWidget(InputWidget): self.setFocusProxy(self.input_field) + self.input_field.focused_in.connect(self._on_input_focus) self.input_field.value_changed.connect(self._on_value_change) + def _on_input_focus(self): + self.focused_in() + def _on_entity_change(self): if self.entity.value != self.input_field.value(): self.set_entity_value() @@ -562,6 +611,9 @@ class PathWidget(BaseWidget): def set_entity_value(self): self.input_field.set_entity_value() + def make_sure_is_visible(self, *args, **kwargs): + return self.input_field.make_sure_is_visible(*args, **kwargs) + def hierarchical_style_update(self): self.update_style() self.input_field.hierarchical_style_update() @@ -632,14 +684,19 @@ class PathWidget(BaseWidget): class PathInputWidget(InputWidget): def _add_inputs_to_layout(self): - self.input_field = QtWidgets.QLineEdit(self.content_widget) + self.input_field = SettingsLineEdit(self.content_widget) placeholder = self.entity.placeholder_text if placeholder: self.input_field.setPlaceholderText(placeholder) self.setFocusProxy(self.input_field) self.content_layout.addWidget(self.input_field) + self.input_field.textChanged.connect(self._on_value_change) + self.input_field.focused_in.connect(self._on_input_focus) + + def _on_input_focus(self): + self.focused_in() def _on_entity_change(self): if self.entity.value != self.input_value(): diff --git a/openpype/tools/settings/settings/list_item_widget.py b/openpype/tools/settings/settings/list_item_widget.py index c9df5caf01..17412a30b9 100644 --- a/openpype/tools/settings/settings/list_item_widget.py +++ b/openpype/tools/settings/settings/list_item_widget.py @@ -18,8 +18,6 @@ class EmptyListItem(QtWidgets.QWidget): add_btn = QtWidgets.QPushButton("+", self) remove_btn = QtWidgets.QPushButton("-", self) - spacer_widget = QtWidgets.QWidget(self) - spacer_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) add_btn.setFocusPolicy(QtCore.Qt.ClickFocus) remove_btn.setEnabled(False) @@ -35,13 +33,12 @@ class EmptyListItem(QtWidgets.QWidget): layout.setSpacing(3) layout.addWidget(add_btn, 0) layout.addWidget(remove_btn, 0) - layout.addWidget(spacer_widget, 1) + layout.addStretch(1) add_btn.clicked.connect(self._on_add_clicked) self.add_btn = add_btn self.remove_btn = remove_btn - self.spacer_widget = spacer_widget def _on_add_clicked(self): self.entity_widget.add_new_item() @@ -101,12 +98,6 @@ class ListItem(QtWidgets.QWidget): self.category_widget, self.entity, self ) - spacer_widget = QtWidgets.QWidget(self) - spacer_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) - spacer_widget.setVisible(False) - - layout.addWidget(spacer_widget, 1) - layout.addWidget(up_btn, 0) layout.addWidget(down_btn, 0) @@ -115,8 +106,6 @@ class ListItem(QtWidgets.QWidget): self.up_btn = up_btn self.down_btn = down_btn - self.spacer_widget = spacer_widget - self._row = -1 self._is_last = False @@ -129,6 +118,9 @@ class ListItem(QtWidgets.QWidget): *args, **kwargs ) + def make_sure_is_visible(self, *args, **kwargs): + return self.input_field.make_sure_is_visible(*args, **kwargs) + @property def is_invalid(self): return self.input_field.is_invalid @@ -275,6 +267,26 @@ class ListWidget(InputWidget): invalid.extend(input_field.get_invalid()) return invalid + def make_sure_is_visible(self, path, scroll_to): + if not path: + return False + + entity_path = self.entity.path + if entity_path == path: + self.set_focus(scroll_to) + return True + + if not path.startswith(entity_path): + return False + + if self.body_widget and not self.body_widget.is_expanded(): + self.body_widget.toggle_content(True) + + for input_field in self.input_fields: + if input_field.make_sure_is_visible(path, scroll_to): + return True + return False + def _on_entity_change(self): # TODO do less inefficient childen_order = [] diff --git a/openpype/tools/settings/settings/list_strict_widget.py b/openpype/tools/settings/settings/list_strict_widget.py index 340db2e8c6..046b6992f6 100644 --- a/openpype/tools/settings/settings/list_strict_widget.py +++ b/openpype/tools/settings/settings/list_strict_widget.py @@ -65,6 +65,21 @@ class ListStrictWidget(BaseWidget): invalid.extend(input_field.get_invalid()) return invalid + def make_sure_is_visible(self, path, scroll_to): + if not path: + return False + + entity_path = self.entity.path + if entity_path == path: + self.set_focus(scroll_to) + return True + + if path.startswith(entity_path): + for input_field in self.input_fields: + if input_field.make_sure_is_visible(path, scroll_to): + return True + return False + def add_widget_to_layout(self, widget, label=None): # Horizontally added children if self.entity.is_horizontal: diff --git a/openpype/tools/settings/settings/multiselection_combobox.py b/openpype/tools/settings/settings/multiselection_combobox.py index 30ecb7b84b..176f4cab8c 100644 --- a/openpype/tools/settings/settings/multiselection_combobox.py +++ b/openpype/tools/settings/settings/multiselection_combobox.py @@ -21,6 +21,8 @@ class ComboItemDelegate(QtWidgets.QStyledItemDelegate): class MultiSelectionComboBox(QtWidgets.QComboBox): value_changed = QtCore.Signal() + focused_in = QtCore.Signal() + ignored_keys = { QtCore.Qt.Key_Up, QtCore.Qt.Key_Down, @@ -56,6 +58,10 @@ class MultiSelectionComboBox(QtWidgets.QComboBox): self.lines = {} self.item_height = None + def focusInEvent(self, event): + self.focused_in.emit() + return super(MultiSelectionComboBox, self).focusInEvent(event) + def mousePressEvent(self, event): """Reimplemented.""" self._popup_is_shown = False diff --git a/openpype/tools/settings/settings/style/style.css b/openpype/tools/settings/settings/style/style.css index 3ce9837a8b..250c15063f 100644 --- a/openpype/tools/settings/settings/style/style.css +++ b/openpype/tools/settings/settings/style/style.css @@ -388,4 +388,32 @@ QTableView::item:pressed, QListView::item:pressed, QTreeView::item:pressed { QTableView::item:selected:active, QTreeView::item:selected:active, QListView::item:selected:active { background: #3d8ec9; -} \ No newline at end of file +} + +#BreadcrumbsPathInput { + padding: 2px; + font-size: 9pt; +} + +#BreadcrumbsButton { + padding-right: 12px; + font-size: 9pt; +} + +#BreadcrumbsButton[empty="1"] { + padding-right: 0px; +} + +#BreadcrumbsButton::menu-button { + width: 12px; + background: rgba(127, 127, 127, 60); +} +#BreadcrumbsButton::menu-button:hover { + background: rgba(127, 127, 127, 90); +} + +#BreadcrumbsPanel { + border: 1px solid #4e5254; + border-radius: 5px; + background: #21252B;; +} diff --git a/openpype/tools/settings/settings/widgets.py b/openpype/tools/settings/settings/widgets.py index b20ce5ed66..34b222dd8e 100644 --- a/openpype/tools/settings/settings/widgets.py +++ b/openpype/tools/settings/settings/widgets.py @@ -9,6 +9,22 @@ from avalon.mongodb import ( from openpype.settings.lib import get_system_settings +class SettingsLineEdit(QtWidgets.QLineEdit): + focused_in = QtCore.Signal() + + def focusInEvent(self, event): + super(SettingsLineEdit, self).focusInEvent(event) + self.focused_in.emit() + + +class SettingsPlainTextEdit(QtWidgets.QPlainTextEdit): + focused_in = QtCore.Signal() + + def focusInEvent(self, event): + super(SettingsPlainTextEdit, self).focusInEvent(event) + self.focused_in.emit() + + class ShadowWidget(QtWidgets.QWidget): def __init__(self, message, parent): super(ShadowWidget, self).__init__(parent) @@ -70,6 +86,8 @@ class IconButton(QtWidgets.QPushButton): class NumberSpinBox(QtWidgets.QDoubleSpinBox): + focused_in = QtCore.Signal() + def __init__(self, *args, **kwargs): min_value = kwargs.pop("minimum", -99999) max_value = kwargs.pop("maximum", 99999) @@ -80,6 +98,10 @@ class NumberSpinBox(QtWidgets.QDoubleSpinBox): self.setMinimum(min_value) self.setMaximum(max_value) + def focusInEvent(self, event): + super(NumberSpinBox, self).focusInEvent(event) + self.focused_in.emit() + def wheelEvent(self, event): if self.hasFocus(): super(NumberSpinBox, self).wheelEvent(event) @@ -93,18 +115,23 @@ class NumberSpinBox(QtWidgets.QDoubleSpinBox): return output -class ComboBox(QtWidgets.QComboBox): +class SettingsComboBox(QtWidgets.QComboBox): value_changed = QtCore.Signal() + focused_in = QtCore.Signal() def __init__(self, *args, **kwargs): - super(ComboBox, self).__init__(*args, **kwargs) + super(SettingsComboBox, self).__init__(*args, **kwargs) self.currentIndexChanged.connect(self._on_change) self.setFocusPolicy(QtCore.Qt.StrongFocus) def wheelEvent(self, event): if self.hasFocus(): - return super(ComboBox, self).wheelEvent(event) + return super(SettingsComboBox, self).wheelEvent(event) + + def focusInEvent(self, event): + self.focused_in.emit() + return super(SettingsComboBox, self).focusInEvent(event) def _on_change(self, *args, **kwargs): self.value_changed.emit() @@ -160,15 +187,13 @@ class ExpandingWidget(QtWidgets.QWidget): after_label_layout = QtWidgets.QHBoxLayout(after_label_widget) after_label_layout.setContentsMargins(0, 0, 0, 0) - spacer_widget = QtWidgets.QWidget(side_line_widget) - side_line_layout = QtWidgets.QHBoxLayout(side_line_widget) side_line_layout.setContentsMargins(5, 10, 0, 10) side_line_layout.addWidget(button_toggle) side_line_layout.addWidget(before_label_widget) side_line_layout.addWidget(label_widget) side_line_layout.addWidget(after_label_widget) - side_line_layout.addWidget(spacer_widget, 1) + side_line_layout.addStretch(1) top_part_layout = QtWidgets.QHBoxLayout(top_part) top_part_layout.setContentsMargins(0, 0, 0, 0) @@ -176,7 +201,6 @@ class ExpandingWidget(QtWidgets.QWidget): before_label_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) after_label_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) - spacer_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) label_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) self.setAttribute(QtCore.Qt.WA_TranslucentBackground) @@ -215,6 +239,9 @@ class ExpandingWidget(QtWidgets.QWidget): self.main_layout.addWidget(content_widget) self.content_widget = content_widget + def is_expanded(self): + return self.button_toggle.isChecked() + def _btn_clicked(self): self.toggle_content(self.button_toggle.isChecked()) @@ -341,31 +368,21 @@ class GridLabelWidget(QtWidgets.QWidget): self.properties = {} + label_widget = QtWidgets.QLabel(label, self) + + label_proxy_layout = QtWidgets.QHBoxLayout() + label_proxy_layout.setContentsMargins(0, 0, 0, 0) + label_proxy_layout.setSpacing(0) + + label_proxy_layout.addWidget(label_widget, 0, QtCore.Qt.AlignRight) + layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(0, 2, 0, 0) layout.setSpacing(0) - label_proxy = QtWidgets.QWidget(self) + layout.addLayout(label_proxy_layout, 0) + layout.addStretch(1) - label_proxy_layout = QtWidgets.QHBoxLayout(label_proxy) - label_proxy_layout.setContentsMargins(0, 0, 0, 0) - label_proxy_layout.setSpacing(0) - - label_widget = QtWidgets.QLabel(label, label_proxy) - spacer_widget_h = SpacerWidget(label_proxy) - label_proxy_layout.addWidget( - spacer_widget_h, 0, alignment=QtCore.Qt.AlignRight - ) - label_proxy_layout.addWidget( - label_widget, 0, alignment=QtCore.Qt.AlignRight - ) - - spacer_widget_v = SpacerWidget(self) - - layout.addWidget(label_proxy, 0) - layout.addWidget(spacer_widget_v, 1) - - label_proxy.setAttribute(QtCore.Qt.WA_TranslucentBackground) label_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) self.label_widget = label_widget @@ -380,6 +397,8 @@ class GridLabelWidget(QtWidgets.QWidget): def mouseReleaseEvent(self, event): if self.input_field: + if event and event.button() == QtCore.Qt.LeftButton: + self.input_field.focused_in() return self.input_field.show_actions_menu(event) return super(GridLabelWidget, self).mouseReleaseEvent(event) diff --git a/openpype/tools/settings/settings/wrapper_widgets.py b/openpype/tools/settings/settings/wrapper_widgets.py index 915a2cf875..b14a226912 100644 --- a/openpype/tools/settings/settings/wrapper_widgets.py +++ b/openpype/tools/settings/settings/wrapper_widgets.py @@ -19,6 +19,14 @@ class WrapperWidget(QtWidgets.QWidget): self.create_ui() + def make_sure_is_visible(self, *args, **kwargs): + changed = False + for input_field in self.input_fields: + if input_field.make_sure_is_visible(*args, **kwargs): + changed = True + break + return changed + def create_ui(self): raise NotImplementedError( "{} does not have implemented `create_ui`.".format( @@ -89,6 +97,14 @@ class CollapsibleWrapper(WrapperWidget): else: body_widget.hide_toolbox(hide_content=False) + def make_sure_is_visible(self, *args, **kwargs): + result = super(CollapsibleWrapper, self).make_sure_is_visible( + *args, **kwargs + ) + if result: + self.body_widget.toggle_content(True) + return result + def add_widget_to_layout(self, widget, label=None): self.input_fields.append(widget) diff --git a/tools/create_env.ps1 b/tools/create_env.ps1 index e2ec401bb3..f19a98f11b 100644 --- a/tools/create_env.ps1 +++ b/tools/create_env.ps1 @@ -50,8 +50,18 @@ function Install-Poetry() { Write-Host "Installing Poetry ... " $python = "python" if (Get-Command "pyenv" -ErrorAction SilentlyContinue) { + if (-not (Test-Path -PathType Leaf -Path "$($openpype_root)\.python-version")) { + $result = & pyenv global + if ($result -eq "no global version configured") { + Write-Host "!!! " -NoNewline -ForegroundColor Red + Write-Host "Using pyenv but having no local or global version of Python set." + Exit-WithCode 1 + } + } $python = & pyenv which python + } + $env:POETRY_HOME="$openpype_root\.poetry" (Invoke-WebRequest -Uri https://raw.githubusercontent.com/python-poetry/poetry/master/install-poetry.py -UseBasicParsing).Content | & $($python) - } diff --git a/vendor/deadline/custom/plugins/GlobalJobPreLoad.py b/vendor/deadline/custom/plugins/GlobalJobPreLoad.py index 41df9d4dc9..8631b035cf 100644 --- a/vendor/deadline/custom/plugins/GlobalJobPreLoad.py +++ b/vendor/deadline/custom/plugins/GlobalJobPreLoad.py @@ -55,9 +55,9 @@ def inject_openpype_environment(deadlinePlugin): "AVALON_TASK, AVALON_APP_NAME" raise RuntimeError(msg) - print("args::{}".format(args)) + print("args:::{}".format(args)) - exit_code = subprocess.call(args, shell=True) + exit_code = subprocess.call(args, cwd=os.path.dirname(openpype_app)) if exit_code != 0: raise RuntimeError("Publishing failed, check worker's log") diff --git a/website/docs/admin_hosts_maya.md b/website/docs/admin_hosts_maya.md index 5e0aa15345..05a231c21a 100644 --- a/website/docs/admin_hosts_maya.md +++ b/website/docs/admin_hosts_maya.md @@ -87,6 +87,26 @@ When you publish your model with top group named like `foo_GRP` it will fail. Bu All regexes used here are in Python variant. ::: +### Maya > Deadline submitter +This plugin provides connection between Maya and Deadline. It is using [Deadline Webservice](https://docs.thinkboxsoftware.com/products/deadline/10.0/1_User%20Manual/manual/web-service.html) to submit jobs to farm. +![Maya > Deadline Settings](assets/maya-admin_submit_maya_job_to_deadline.png) + +You can set various aspects of scene submission to farm with per-project settings in **Setting UI**. + + - **Optional** will mark sumission plugin optional + - **Active** will enable/disable plugin + - **Tile Assembler Plugin** will set what should be used to assemble tiles on Deadline. Either **Open Image IO** will be used +or Deadlines **Draft Tile Assembler**. + - **Use Published scene** enable to render from published scene instead of scene in work area. Rendering from published files is much safer. + - **Use Asset dependencies** will mark job pending on farm until asset dependencies are fulfilled - for example Deadline will wait for scene file to be synced to cloud, etc. + - **Group name** use specific Deadline group for the job. + - **Limit Groups** use these Deadline Limit groups for the job. + - **Additional `JobInfo` data** JSON of additional Deadline options that will be embedded in `JobInfo` part of the submission data. + - **Additional `PluginInfo` data** JSON of additional Deadline options that will be embedded in `PluginInfo` part of the submission data. + - **Scene patches** - configure mechanism to add additional lines to published Maya Ascii scene files before they are used for rendering. +This is useful to fix some specific renderer glitches and advanced hacking of Maya Scene files. `Patch name` is label for patch for easier orientation. +`Patch regex` is regex used to find line in file, after `Patch line` string is inserted. Note that you need to add line ending. + ## Custom Menu You can add your custom tools menu into Maya by extending definitions in **Maya -> Scripts Menu Definition**. ![Custom menu definition](assets/maya-admin_scriptsmenu.png) @@ -94,4 +114,9 @@ You can add your custom tools menu into Maya by extending definitions in **Maya :::note Work in progress This is still work in progress. Menu definition will be handled more friendly with widgets and not raw json. -::: \ No newline at end of file +::: + +## Multiplatform path mapping +You can configure path mapping using Maya `dirmap` command. This will add bi-directional mapping between +list of paths specified in **Settings**. You can find it in **Settings -> Project Settings -> Maya -> Maya Directory Mapping** +![Dirmap settings](assets/maya-admin_dirmap_settings.png) diff --git a/website/docs/assets/maya-admin_dirmap_settings.png b/website/docs/assets/maya-admin_dirmap_settings.png new file mode 100644 index 0000000000..9d5780dfc8 Binary files /dev/null and b/website/docs/assets/maya-admin_dirmap_settings.png differ diff --git a/website/docs/assets/maya-admin_submit_maya_job_to_deadline.png b/website/docs/assets/maya-admin_submit_maya_job_to_deadline.png new file mode 100644 index 0000000000..56b720dc5d Binary files /dev/null and b/website/docs/assets/maya-admin_submit_maya_job_to_deadline.png differ diff --git a/website/static/img/logos/pypeclub_black.svg b/website/static/img/logos/pypeclub_black.svg index b749edbdb3..6c209977fe 100644 --- a/website/static/img/logos/pypeclub_black.svg +++ b/website/static/img/logos/pypeclub_black.svg @@ -1,8 +1,8 @@ - - + + @@ -20,7 +20,21 @@ - .club + + + + + + + + + + + + + + + diff --git a/website/static/img/logos/pypeclub_color_white.svg b/website/static/img/logos/pypeclub_color_white.svg index c82946d82b..ffa194aa47 100644 --- a/website/static/img/logos/pypeclub_color_white.svg +++ b/website/static/img/logos/pypeclub_color_white.svg @@ -1,26 +1,40 @@ - - + + - + - + - + - .club + + + + + + + + + + + + + + + diff --git a/website/static/img/logos/pypeclub_white.svg b/website/static/img/logos/pypeclub_white.svg index b634c210b1..3bf4159f9c 100644 --- a/website/static/img/logos/pypeclub_white.svg +++ b/website/static/img/logos/pypeclub_white.svg @@ -1,8 +1,8 @@ - - + + @@ -20,7 +20,21 @@ - .club + + + + + + + + + + + + + + +