diff --git a/pype/hooks/global/pre_global_host_data.py b/pype/hooks/global/pre_global_host_data.py index 4910d08010..cb497814f5 100644 --- a/pype/hooks/global/pre_global_host_data.py +++ b/pype/hooks/global/pre_global_host_data.py @@ -11,7 +11,9 @@ from pype.api import ( from pype.lib import ( env_value_to_bool, PreLaunchHook, - ApplicationLaunchFailed + ApplicationLaunchFailed, + get_workdir_data, + get_workdir_with_workdir_data, ) import acre @@ -140,17 +142,15 @@ class GlobalHostDataHook(PreLaunchHook): ) return - workdir_data = self._prepare_workdir_data( - project_doc, asset_doc, task_name + workdir_data = get_workdir_data( + project_doc, asset_doc, task_name, self.host_name ) self.data["workdir_data"] = workdir_data - hierarchy = workdir_data["hierarchy"] anatomy = self.data["anatomy"] try: - anatomy_filled = anatomy.format(workdir_data) - workdir = os.path.normpath(anatomy_filled["work"]["folder"]) + workdir = get_workdir_with_workdir_data(workdir_data, anatomy) if not os.path.exists(workdir): self.log.debug( "Creating workdir folder: \"{}\"".format(workdir) @@ -168,7 +168,6 @@ class GlobalHostDataHook(PreLaunchHook): "AVALON_TASK": task_name, "AVALON_APP": self.host_name, "AVALON_APP_NAME": self.app_name, - "AVALON_HIERARCHY": hierarchy, "AVALON_WORKDIR": workdir } self.log.debug( @@ -180,21 +179,6 @@ class GlobalHostDataHook(PreLaunchHook): self.prepare_last_workfile(workdir) - def _prepare_workdir_data(self, project_doc, asset_doc, task_name): - hierarchy = "/".join(asset_doc["data"]["parents"]) - - data = { - "project": { - "name": project_doc["name"], - "code": project_doc["data"].get("code") - }, - "task": task_name, - "asset": asset_doc["name"], - "app": self.host_name, - "hierarchy": hierarchy - } - return data - def prepare_last_workfile(self, workdir): """last workfile workflow preparation. diff --git a/pype/hosts/maya/menu.py b/pype/hosts/maya/menu.py index 288502a1e1..fa7690bca7 100644 --- a/pype/hosts/maya/menu.py +++ b/pype/hosts/maya/menu.py @@ -13,12 +13,14 @@ self._menu = os.environ.get('PYPE_STUDIO_NAME') or "Pype" log = logging.getLogger(__name__) -def _get_menu(): +def _get_menu(menu_name=None): """Return the menu instance if it currently exists in Maya""" + if menu_name is None: + menu_name = self._menu widgets = dict(( w.objectName(), w) for w in QtWidgets.QApplication.allWidgets()) - menu = widgets.get(self._menu) + menu = widgets.get(menu_name) return menu @@ -40,10 +42,51 @@ def deferred(): command=lambda *args: mayalookassigner.show() ) + def modify_workfiles(): + from pype.tools import workfiles + + def launch_workfiles_app(*_args, **_kwargs): + workfiles.show( + os.path.join( + cmds.workspace(query=True, rootDirectory=True), + cmds.workspace(fileRuleEntry="scene") + ), + parent=pipeline._parent + ) + + # Find the pipeline menu + top_menu = _get_menu(pipeline._menu) + + # Try to find workfile tool action in the menu + workfile_action = None + for action in top_menu.actions(): + if action.text() == "Work Files": + workfile_action = action + break + + # Add at the top of menu if "Work Files" action was not found + after_action = "" + if workfile_action: + # Use action's object name for `insertAfter` argument + after_action = workfile_action.objectName() + + # Insert action to menu + cmds.menuItem( + "Work Files", + parent=pipeline._menu, + command=launch_workfiles_app, + insertAfter=after_action + ) + + # Remove replaced action + if workfile_action: + top_menu.removeAction(workfile_action) + log.info("Attempting to install scripts menu..") add_build_workfiles_item() add_look_assigner_item() + modify_workfiles() try: import scriptsmenu.launchformaya as launchformaya diff --git a/pype/hosts/nuke/menu.py b/pype/hosts/nuke/menu.py index f35cebfde0..a8d5090da9 100644 --- a/pype/hosts/nuke/menu.py +++ b/pype/hosts/nuke/menu.py @@ -1,9 +1,11 @@ +import os import nuke from avalon.api import Session from pype.hosts.nuke import lib from ...lib import BuildWorkfile from pype.api import Logger +from pype.tools import workfiles log = Logger().get_logger(__name__) @@ -12,6 +14,24 @@ def install(): menubar = nuke.menu("Nuke") menu = menubar.findItem(Session["AVALON_LABEL"]) workfile_settings = lib.WorkfileSettings + + # replace reset resolution from avalon core to pype's + name = "Work Files..." + rm_item = [ + (i, item) for i, item in enumerate(menu.items()) if name in item.name() + ][0] + + log.debug("Changing Item: {}".format(rm_item)) + + menu.removeItem(rm_item[1].name()) + menu.addCommand( + name, + lambda: workfiles.show( + os.environ["AVALON_WORKDIR"] + ), + index=(rm_item[0]) + ) + # replace reset resolution from avalon core to pype's name = "Reset Resolution" new_name = "Set Resolution" diff --git a/pype/lib/__init__.py b/pype/lib/__init__.py index b393c9a177..7e33577f3e 100644 --- a/pype/lib/__init__.py +++ b/pype/lib/__init__.py @@ -35,6 +35,15 @@ from .avalon_context import ( get_hierarchy, get_linked_assets, get_latest_version, + + get_workdir_data, + get_workdir, + get_workdir_with_workdir_data, + + create_workfile_doc, + save_workfile_data_to_doc, + get_workfile_doc, + BuildWorkfile ) @@ -103,6 +112,15 @@ __all__ = [ "get_hierarchy", "get_linked_assets", "get_latest_version", + + "get_workdir_data", + "get_workdir", + "get_workdir_with_workdir_data", + + "create_workfile_doc", + "save_workfile_data_to_doc", + "get_workfile_doc", + "BuildWorkfile", "ApplicationLaunchFailed", diff --git a/pype/lib/avalon_context.py b/pype/lib/avalon_context.py index 3a18e956d9..fd4155703e 100644 --- a/pype/lib/avalon_context.py +++ b/pype/lib/avalon_context.py @@ -1,11 +1,13 @@ import os import json import re +import copy import logging import collections import functools from pype.settings import get_project_settings +from .anatomy import Anatomy # avalon module is not imported at the top # - may not be in path at the time of pype.lib initialization @@ -246,6 +248,229 @@ def get_latest_version(asset_name, subset_name, dbcon=None, project_name=None): return version_doc +def get_workdir_data(project_doc, asset_doc, task_name, host_name): + """Prepare data for workdir template filling from entered information. + + Args: + project_doc (dict): Mongo document of project from MongoDB. + asset_doc (dict): Mongo document of asset from MongoDB. + task_name (str): Task name for which are workdir data preapred. + host_name (str): Host which is used to workdir. This is required + because workdir template may contain `{app}` key. + + Returns: + dict: Data prepared for filling workdir template. + """ + hierarchy = "/".join(asset_doc["data"]["parents"]) + + data = { + "project": { + "name": project_doc["name"], + "code": project_doc["data"].get("code") + }, + "task": task_name, + "asset": asset_doc["name"], + "app": host_name, + "hierarchy": hierarchy + } + return data + + +def get_workdir_with_workdir_data( + workdir_data, anatomy=None, project_name=None, template_key=None +): + """Fill workdir path from entered data and project's anatomy. + + It is possible to pass only project's name instead of project's anatomy but + one of them **must** be entered. It is preffered to enter anatomy if is + available as initialization of a new Anatomy object may be time consuming. + + Args: + workdir_data (dict): Data to fill workdir template. + anatomy (Anatomy): Anatomy object for specific project. Optional if + `project_name` is entered. + project_name (str): Project's name. Optional if `anatomy` is entered + otherwise Anatomy object is created with using the project name. + template_key (str): Key of work templates in anatomy templates. By + default is seto to `"work"`. + + Returns: + TemplateResult: Workdir path. + + Raises: + ValueError: When both `anatomy` and `project_name` are set to None. + """ + if not anatomy and not project_name: + raise ValueError(( + "Missing required arguments one of `project_name` or `anatomy`" + " must be entered." + )) + + if not anatomy: + anatomy = Anatomy(project_name) + + if not template_key: + template_key = "work" + + anatomy_filled = anatomy.format(workdir_data) + # Output is TemplateResult object which contain usefull data + return anatomy_filled[template_key]["folder"] + + +def get_workdir( + project_doc, + asset_doc, + task_name, + host_name, + anatomy=None, + template_key=None +): + """Fill workdir path from entered data and project's anatomy. + + Args: + project_doc (dict): Mongo document of project from MongoDB. + asset_doc (dict): Mongo document of asset from MongoDB. + task_name (str): Task name for which are workdir data preapred. + host_name (str): Host which is used to workdir. This is required + because workdir template may contain `{app}` key. In `Session` + is stored under `AVALON_APP` key. + anatomy (Anatomy): Optional argument. Anatomy object is created using + project name from `project_doc`. It is preffered to pass this + argument as initialization of a new Anatomy object may be time + consuming. + template_key (str): Key of work templates in anatomy templates. Default + value is defined in `get_workdir_with_workdir_data`. + + Returns: + TemplateResult: Workdir path. + """ + if not anatomy: + anatomy = Anatomy(project_doc["name"]) + + workdir_data = get_workdir_data( + project_doc, asset_doc, task_name, host_name + ) + # Output is TemplateResult object which contain usefull data + return get_workdir_with_workdir_data(workdir_data, anatomy, template_key) + + +@with_avalon +def get_workfile_doc(asset_id, task_name, filename, dbcon=None): + """Return workfile document for entered context. + + Do not use this method to get more than one document. In that cases use + custom query as this will return documents from database one by one. + + Args: + asset_id (ObjectId): Mongo ID of an asset under which workfile belongs. + task_name (str): Name of task under which the workfile belongs. + filename (str): Name of a workfile. + dbcon (AvalonMongoDB): Optionally enter avalon AvalonMongoDB object and + `avalon.io` is used if not entered. + + Returns: + dict: Workfile document or None. + """ + # Use avalon.io if dbcon is not entered + if not dbcon: + dbcon = avalon.io + + return dbcon.find_one({ + "type": "workfile", + "parent": asset_id, + "task_name": task_name, + "filename": filename + }) + + +@with_avalon +def create_workfile_doc(asset_doc, task_name, filename, workdir, dbcon=None): + """Creates or replace workfile document in mongo. + + Do not use this method to update data. This method will remove all + additional data from existing document. + + Args: + asset_doc (dict): Document of asset under which workfile belongs. + task_name (str): Name of task for which is workfile related to. + filename (str): Filename of workfile. + workdir (str): Path to directory where `filename` is located. + dbcon (AvalonMongoDB): Optionally enter avalon AvalonMongoDB object and + `avalon.io` is used if not entered. + """ + # Use avalon.io if dbcon is not entered + if not dbcon: + dbcon = avalon.io + + # Filter of workfile document + doc_filter = { + "type": "workfile", + "parent": asset_doc["_id"], + "task_name": task_name, + "filename": filename + } + # Document data are copy of filter + doc_data = copy.deepcopy(doc_filter) + + # Prepare project for workdir data + project_doc = dbcon.find_one({"type": "project"}) + workdir_data = get_workdir_data( + project_doc, asset_doc, task_name, dbcon.Session["AVALON_APP"] + ) + # Prepare anatomy + anatomy = Anatomy(project_doc["name"]) + # Get workdir path (result is anatomy.TemplateResult) + template_workdir = get_workdir_with_workdir_data(workdir_data, anatomy) + template_workdir_path = str(template_workdir).replace("\\", "/") + + # Replace slashses in workdir path where workfile is located + mod_workdir = workdir.replace("\\", "/") + + # Replace workdir from templates with rootless workdir + rootles_workdir = mod_workdir.replace( + template_workdir_path, + template_workdir.rootless.replace("\\", "/") + ) + + doc_data["schema"] = "pype:workfile-1.0" + doc_data["files"] = ["/".join([rootles_workdir, filename])] + doc_data["data"] = {} + + dbcon.replace_one( + doc_filter, + doc_data, + upsert=True + ) + + +@with_avalon +def save_workfile_data_to_doc(workfile_doc, data, dbcon=None): + if not workfile_doc: + # TODO add log message + return + + if not data: + return + + # Use avalon.io if dbcon is not entered + if not dbcon: + dbcon = avalon.io + + # Convert data to mongo modification keys/values + # - this is naive implementation which does not expect nested + # dictionaries + set_data = {} + for key, value in data.items(): + new_key = "data.{}".format(key) + set_data[new_key] = value + + # Update workfile document with data + dbcon.update_one( + {"_id": workfile_doc["_id"]}, + {"$set": set_data} + ) + + class BuildWorkfile: """Wrapper for build workfile process. diff --git a/pype/tools/workfiles/README.md b/pype/tools/workfiles/README.md new file mode 100644 index 0000000000..92ad4a8577 --- /dev/null +++ b/pype/tools/workfiles/README.md @@ -0,0 +1,143 @@ +# Workfiles App + +The Workfiles app facilitates easy saving, creation and launching of work files. + +The current supported hosts are: + +- Maya +- Houdini +- Fusion + +The app is available inside hosts via. the ```Avalon > Work Files``` menu. + +## Enabling Workfiles on launch + +By default the Workfiles app will not launch on startup, so it has to be explicitly enabled in a config. + +```python +workfiles.show() +``` + +## Naming Files + +Workfiles app enables user to easily save and create new work files. + +The user is presented with a two parameters; ```version``` and ```comment```. The name of the work file is determined from a template. + +### ```Next Available Version``` + +Will search for the next version number that is not in use. + +## Templates + +The default template for work files is ```{task[name]}_v{version:0>4}<_{comment}>```. Launching Maya on an animation task and creating a version 1 will result in ```animation_v0001.ma```. Adding "blocking" to the optional comment input will result in ```animation_v0001_blocking.ma```. + +This template can be customized per project with the ```workfile``` template. + +There are other variables to customize the template with: + +```python +{ + "project": project, # The project data from the database. + "asset": asset, # The asset data from the database. + "task": { + "label": label, # Label of task chosen. + "name": name # Sanitize version of the label. + }, + "user": user, # Name of the user on the machine. + "version": version, # Chosen version of the user. + "comment": comment, # Chosen comment of the user. +} +``` + +### Optional template groups + +The default template contains an optional template group ```<_{comment}>```. If any template group (```{comment}```) within angle bracket ```<>``` does not exist, the whole optional group is discarded. + + +## Implementing a new host integration for Work Files + +For the Work Files tool to work with a new host integration the host must +implement the following functions: + +- `file_extensions()`: The files the host should allow to open and show in the Work Files view. +- `open_file(filepath)`: Open a file. +- `save_file(filepath)`: Save the current file. This should return None if it failed to save, and return the path if it succeeded +- `has_unsaved_changes()`: Return whether the current scene has unsaved changes. +- `current_file()`: The path to the current file. None if not saved. +- `work_root()`: The path to where the work files for this app should be saved. + +Here's an example code layout: + +```python +def file_extensions(): + """Return the filename extension formats that should be shown. + + Note: + The first entry in the list will be used as the default file + format to save to when the current scene is not saved yet. + + Returns: + list: A list of the file extensions supported by Work Files. + + """ + return list() + + +def has_unsaved_changes(): + """Return whether current file has unsaved modifications.""" + + +def save_file(filepath): + """Save to filepath. + + This should return None if it failed to save, and return the path if it + succeeded. + """ + pass + + +def open_file(filepath): + """Open file""" + pass + + +def current_file(): + """Return path to currently open file or None if not saved. + + Returns: + str or None: The full path to current file or None when not saved. + + """ + pass + + +def work_root(): + """Return the default root for the Host to browse in for Work Files + + Returns: + str: The path to look in. + + """ + pass +``` + +#### Work Files Scenes root (AVALON_SCENEDIR) + +Whenever the host application has no built-in implementation that defines +where scene files should be saved to then the Work Files API for that host +should fall back to the `AVALON_SCENEDIR` variable in `api.Session`. + +When `AVALON_SCENEDIR` is set the directory is the relative folder inside the +`AVALON_WORKDIR`. Otherwise, when it is not set or empty it should fall back +to the Work Directory's root, `AVALON_WORKDIR` + +```python +AVALON_WORKDIR="/path/to/work" +AVALON_SCENEDIR="scenes" +# Result: /path/to/work/scenes + +AVALON_WORKDIR="/path/to/work" +AVALON_SCENEDIR=None +# Result: /path/to/work +``` \ No newline at end of file diff --git a/pype/tools/workfiles/__init__.py b/pype/tools/workfiles/__init__.py new file mode 100644 index 0000000000..cde7293931 --- /dev/null +++ b/pype/tools/workfiles/__init__.py @@ -0,0 +1,9 @@ +from .app import ( + show, + Window +) + +__all__ = [ + "show", + "Window" +] diff --git a/pype/tools/workfiles/app.py b/pype/tools/workfiles/app.py new file mode 100644 index 0000000000..e6b211152a --- /dev/null +++ b/pype/tools/workfiles/app.py @@ -0,0 +1,1166 @@ +import sys +import os +import copy +import getpass +import shutil +import logging +import datetime + +import Qt +from Qt import QtWidgets, QtCore +from avalon import style, io, api, pipeline + +from avalon.tools import lib as tools_lib +from avalon.tools.widgets import AssetWidget +from avalon.tools.models import TasksModel +from avalon.tools.delegates import PrettyTimeDelegate + +from .model import FilesModel +from .view import FilesView + +from pype.lib import ( + Anatomy, + get_workdir, + get_workfile_doc, + create_workfile_doc, + save_workfile_data_to_doc +) + +log = logging.getLogger(__name__) + +module = sys.modules[__name__] +module.window = None + + +class NameWindow(QtWidgets.QDialog): + """Name Window to define a unique filename inside a root folder + + The filename will be based on the "workfile" template defined in the + project["config"]["template"]. + + """ + + def __init__(self, parent, root, anatomy, template_key, session=None): + super(NameWindow, self).__init__(parent=parent) + self.setWindowFlags(self.windowFlags() | QtCore.Qt.FramelessWindowHint) + + self.result = None + self.host = api.registered_host() + self.root = root + self.work_file = None + + if not session: + # Fallback to active session + session = api.Session + + # Set work file data for template formatting + asset_name = session["AVALON_ASSET"] + project_doc = io.find_one({ + "type": "project" + }) + self.data = { + "project": { + "name": project_doc["name"], + "code": project_doc["data"].get("code") + }, + "asset": asset_name, + "task": session["AVALON_TASK"], + "version": 1, + "user": getpass.getuser(), + "comment": "", + "ext": None + } + + # Store project anatomy + self.anatomy = anatomy + self.template = anatomy.templates[template_key]["file"] + self.template_key = template_key + + # Btns widget + btns_widget = QtWidgets.QWidget(self) + + btn_ok = QtWidgets.QPushButton("Ok", btns_widget) + btn_cancel = QtWidgets.QPushButton("Cancel", btns_widget) + + btns_layout = QtWidgets.QHBoxLayout(btns_widget) + btns_layout.addWidget(btn_ok) + btns_layout.addWidget(btn_cancel) + + # Inputs widget + inputs_widget = QtWidgets.QWidget(self) + + # Version widget + version_widget = QtWidgets.QWidget(inputs_widget) + + # Version number input + version_input = QtWidgets.QSpinBox(version_widget) + version_input.setMinimum(1) + version_input.setMaximum(9999) + + # Last version checkbox + last_version_check = QtWidgets.QCheckBox( + "Next Available Version", version_widget + ) + last_version_check.setChecked(True) + + version_layout = QtWidgets.QHBoxLayout(version_widget) + version_layout.setContentsMargins(0, 0, 0, 0) + version_layout.addWidget(version_input) + version_layout.addWidget(last_version_check) + + # Preview widget + preview_label = QtWidgets.QLabel("Preview filename", inputs_widget) + + # Subversion input + subversion_input = QtWidgets.QLineEdit(inputs_widget) + subversion_input.setPlaceholderText("Will be part of filename.") + + # Extensions combobox + ext_combo = QtWidgets.QComboBox(inputs_widget) + ext_combo.addItems(self.host.file_extensions()) + + # Build inputs + inputs_layout = QtWidgets.QFormLayout(inputs_widget) + # Add version only if template contain version key + # - since the version can be padded with "{version:0>4}" we only search + # for "{version". + if "{version" in self.template: + inputs_layout.addRow("Version:", version_widget) + + # Add subversion only if template containt `{comment}` + if "{comment}" in self.template: + inputs_layout.addRow("Subversion:", subversion_input) + inputs_layout.addRow("Extension:", ext_combo) + inputs_layout.addRow("Preview:", preview_label) + + # Build layout + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.addWidget(inputs_widget) + main_layout.addWidget(btns_widget) + + # Singal callback registration + version_input.valueChanged.connect(self.on_version_spinbox_changed) + last_version_check.stateChanged.connect( + self.on_version_checkbox_changed + ) + + subversion_input.textChanged.connect(self.on_comment_changed) + ext_combo.currentIndexChanged.connect(self.on_extension_changed) + + btn_ok.pressed.connect(self.on_ok_pressed) + btn_cancel.pressed.connect(self.on_cancel_pressed) + + # Allow "Enter" key to accept the save. + btn_ok.setDefault(True) + + # Force default focus to comment, some hosts didn't automatically + # apply focus to this line edit (e.g. Houdini) + subversion_input.setFocus() + + # Store widgets + self.btn_ok = btn_ok + + self.version_widget = version_widget + + self.version_input = version_input + self.last_version_check = last_version_check + + self.preview_label = preview_label + self.subversion_input = subversion_input + self.ext_combo = ext_combo + + self.refresh() + + def on_version_spinbox_changed(self, value): + self.data["version"] = value + self.refresh() + + def on_version_checkbox_changed(self, _value): + self.refresh() + + def on_comment_changed(self, text): + self.data["comment"] = text + self.refresh() + + def on_extension_changed(self): + ext = self.ext_combo.currentText() + if ext == self.data["ext"]: + return + self.data["ext"] = ext + self.refresh() + + def on_ok_pressed(self): + self.result = self.work_file + self.close() + + def on_cancel_pressed(self): + self.close() + + def get_result(self): + return self.result + + def get_work_file(self): + data = copy.deepcopy(self.data) + if not data["comment"]: + data.pop("comment", None) + + data["ext"] = data["ext"][1:] + + anatomy_filled = self.anatomy.format(data) + return anatomy_filled[self.template_key]["file"] + + def refresh(self): + extensions = self.host.file_extensions() + extension = self.data["ext"] + if extension is None: + # Define saving file extension + current_file = self.host.current_file() + if current_file: + # Match the extension of current file + _, extension = os.path.splitext(current_file) + else: + extension = extensions[0] + + if extension != self.data["ext"]: + self.data["ext"] = extension + index = self.ext_combo.findText( + extension, QtCore.Qt.MatchFixedString + ) + if index >= 0: + self.ext_combo.setCurrentIndex(index) + + if not self.last_version_check.isChecked(): + self.version_input.setEnabled(True) + self.data["version"] = self.version_input.value() + + work_file = self.get_work_file() + + else: + self.version_input.setEnabled(False) + + data = copy.deepcopy(self.data) + template = str(self.template) + + if not data["comment"]: + data.pop("comment", None) + + data["ext"] = data["ext"][1:] + + version = api.last_workfile_with_version( + self.root, template, data, extensions + )[1] + + if version is None: + version = 1 + else: + version += 1 + + found_valid_version = False + # Check if next version is valid version and give a chance to try + # next 100 versions + for idx in range(100): + # Store version to data + self.data["version"] = version + + work_file = self.get_work_file() + # Safety check + path = os.path.join(self.root, work_file) + if not os.path.exists(path): + found_valid_version = True + break + + # Try next version + version += 1 + # Log warning + if idx == 0: + log.warning(( + "BUG: Function `last_workfile_with_version` " + "didn't return last version." + )) + # Raise exception if even 100 version fallback didn't help + if not found_valid_version: + raise AssertionError( + "This is a bug. Couldn't find valid version!" + ) + + self.work_file = work_file + + path_exists = os.path.exists(os.path.join(self.root, work_file)) + + self.btn_ok.setEnabled(not path_exists) + + if path_exists: + self.preview_label.setText( + "Cannot create \"{0}\" because file exists!" + "".format(work_file) + ) + else: + self.preview_label.setText( + "{0}".format(work_file) + ) + + +class TasksWidget(QtWidgets.QWidget): + """Widget showing active Tasks""" + + task_changed = QtCore.Signal() + + def __init__(self, parent=None): + super(TasksWidget, self).__init__(parent) + self.setContentsMargins(0, 0, 0, 0) + + view = QtWidgets.QTreeView() + view.setIndentation(0) + model = TasksModel(io) + view.setModel(model) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(view) + + # Hide the default tasks "count" as we don't need that data here. + view.setColumnHidden(1, True) + + selection = view.selectionModel() + selection.currentChanged.connect(self.task_changed) + + self.models = { + "tasks": model + } + + self.widgets = { + "view": view, + } + + self._last_selected_task = None + + def set_asset(self, asset): + if asset is None: + # Asset deselected + return + + # Try and preserve the last selected task and reselect it + # after switching assets. If there's no currently selected + # asset keep whatever the "last selected" was prior to it. + current = self.get_current_task() + if current: + self._last_selected_task = current + + self.models["tasks"].set_assets(asset_docs=[asset]) + + if self._last_selected_task: + self.select_task(self._last_selected_task) + + # Force a task changed emit. + self.task_changed.emit() + + def select_task(self, task): + """Select a task by name. + + If the task does not exist in the current model then selection is only + cleared. + + Args: + task (str): Name of the task to select. + + """ + + # Clear selection + view = self.widgets["view"] + model = view.model() + selection_model = view.selectionModel() + selection_model.clearSelection() + + # Select the task + mode = selection_model.Select | selection_model.Rows + for row in range(model.rowCount(QtCore.QModelIndex())): + index = model.index(row, 0, QtCore.QModelIndex()) + name = index.data(QtCore.Qt.DisplayRole) + if name == task: + selection_model.select(index, mode) + + # Set the currently active index + view.setCurrentIndex(index) + + def get_current_task(self): + """Return name of task at current index (selected) + + Returns: + str: Name of the current task. + + """ + view = self.widgets["view"] + index = view.currentIndex() + index = index.sibling(index.row(), 0) # ensure column zero for name + + selection = view.selectionModel() + if selection.isSelected(index): + # Ignore when the current task is not selected as the "No task" + # placeholder might be the current index even though it's + # disallowed to be selected. So we only return if it is selected. + return index.data(QtCore.Qt.DisplayRole) + + +class FilesWidget(QtWidgets.QWidget): + """A widget displaying files that allows to save and open files.""" + file_selected = QtCore.Signal(str) + workfile_created = QtCore.Signal(str) + + def __init__(self, parent=None): + super(FilesWidget, self).__init__(parent=parent) + + # Setup + self._asset = None + self._task = None + + # Pype's anatomy object for current project + self.anatomy = Anatomy(io.Session["AVALON_PROJECT"]) + # Template key used to get work template from anatomy templates + # TODO change template key based on task + self.template_key = "work" + + # This is not root but workfile directory + self.root = None + self.host = api.registered_host() + + # Whether to automatically select the latest modified + # file on a refresh of the files model. + self.auto_select_latest_modified = True + + # Avoid crash in Blender and store the message box + # (setting parent doesn't work as it hides the message box) + self._messagebox = None + + files_view = FilesView(self) + + # Create the Files model + extensions = set(self.host.file_extensions()) + files_model = FilesModel(file_extensions=extensions) + + # Create proxy model for files to be able sort and filter + proxy_model = QtCore.QSortFilterProxyModel() + proxy_model.setSourceModel(files_model) + proxy_model.setDynamicSortFilter(True) + proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) + + # Set up the file list tree view + files_view.setModel(proxy_model) + files_view.setSortingEnabled(True) + files_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + + # Date modified delegate + time_delegate = PrettyTimeDelegate() + files_view.setItemDelegateForColumn(1, time_delegate) + files_view.setIndentation(3) # smaller indentation + + # Default to a wider first filename column it is what we mostly care + # about and the date modified is relatively small anyway. + files_view.setColumnWidth(0, 330) + + # Filtering input + filter_input = QtWidgets.QLineEdit(self) + filter_input.textChanged.connect(proxy_model.setFilterFixedString) + filter_input.setPlaceholderText("Filter files..") + + # Home Page + # Build buttons widget for files widget + btns_widget = QtWidgets.QWidget(self) + btn_save = QtWidgets.QPushButton("Save As", btns_widget) + btn_browse = QtWidgets.QPushButton("Browse", btns_widget) + btn_open = QtWidgets.QPushButton("Open", btns_widget) + + btns_layout = QtWidgets.QHBoxLayout(btns_widget) + btns_layout.setContentsMargins(0, 0, 0, 0) + btns_layout.addWidget(btn_open) + btns_layout.addWidget(btn_browse) + btns_layout.addWidget(btn_save) + + # Build files widgets for home page + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(filter_input) + main_layout.addWidget(files_view) + main_layout.addWidget(btns_widget) + + # Register signal callbacks + files_view.doubleClickedLeft.connect(self.on_open_pressed) + files_view.customContextMenuRequested.connect(self.on_context_menu) + files_view.selectionModel().selectionChanged.connect( + self.on_file_select + ) + + btn_open.pressed.connect(self.on_open_pressed) + btn_browse.pressed.connect(self.on_browse_pressed) + btn_save.pressed.connect(self.on_save_as_pressed) + + # Store attributes + self.time_delegate = time_delegate + + self.filter_input = filter_input + + self.files_view = files_view + self.files_model = files_model + + self.btns_widget = btns_widget + self.btn_open = btn_open + self.btn_browse = btn_browse + self.btn_save = btn_save + + def set_asset_task(self, asset, task): + self._asset = asset + self._task = task + + # Define a custom session so we can query the work root + # for a "Work area" that is not our current Session. + # This way we can browse it even before we enter it. + if self._asset and self._task: + session = self._get_session() + self.root = self.host.work_root(session) + self.files_model.set_root(self.root) + + else: + self.files_model.set_root(None) + + # Disable/Enable buttons based on available files in model + has_filenames = self.files_model.has_filenames() + self.btn_browse.setEnabled(has_filenames) + self.btn_open.setEnabled(has_filenames) + if not has_filenames: + # Manually trigger file selection + self.on_file_select() + + def _get_session(self): + """Return a modified session for the current asset and task""" + + session = api.Session.copy() + changes = pipeline.compute_session_changes( + session, + asset=self._asset, + task=self._task + ) + session.update(changes) + + return session + + def _enter_session(self): + """Enter the asset and task session currently selected""" + + session = api.Session.copy() + changes = pipeline.compute_session_changes( + session, + asset=self._asset, + task=self._task + ) + if not changes: + # Return early if we're already in the right Session context + # to avoid any unwanted Task Changed callbacks to be triggered. + return + + api.update_current_task(asset=self._asset, task=self._task) + + def open_file(self, filepath): + host = self.host + if host.has_unsaved_changes(): + result = self.save_changes_prompt() + if result is None: + # Cancel operation + return False + + # Save first if has changes + if result: + current_file = host.current_file() + if not current_file: + # If the user requested to save the current scene + # we can't actually automatically do so if the current + # file has not been saved with a name yet. So we'll have + # to opt out. + log.error("Can't save scene with no filename. Please " + "first save your work file using 'Save As'.") + return + + # Save current scene, continue to open file + host.save_file(current_file) + + self._enter_session() + host.open_file(filepath) + self.window().close() + + def save_changes_prompt(self): + self._messagebox = messagebox = QtWidgets.QMessageBox() + + messagebox.setWindowFlags(QtCore.Qt.FramelessWindowHint) + messagebox.setIcon(messagebox.Warning) + messagebox.setWindowTitle("Unsaved Changes!") + messagebox.setText( + "There are unsaved changes to the current file." + "\nDo you want to save the changes?" + ) + messagebox.setStandardButtons( + messagebox.Yes | messagebox.No | messagebox.Cancel + ) + + # Parenting the QMessageBox to the Widget seems to crash + # so we skip parenting and explicitly apply the stylesheet. + messagebox.setStyleSheet(style.load_stylesheet()) + + result = messagebox.exec_() + if result == messagebox.Yes: + return True + elif result == messagebox.No: + return False + return None + + def get_filename(self): + """Show save dialog to define filename for save or duplicate + + Returns: + str: The filename to create. + + """ + session = self._get_session() + + window = NameWindow( + parent=self, + root=self.root, + anatomy=self.anatomy, + template_key=self.template_key, + session=session + ) + window.exec_() + + return window.get_result() + + def on_duplicate_pressed(self): + work_file = self.get_filename() + if not work_file: + return + + src = self._get_selected_filepath() + dst = os.path.join(self.root, work_file) + shutil.copy(src, dst) + + self.workfile_created.emit(dst) + + self.refresh() + + def _get_selected_filepath(self): + """Return current filepath selected in view""" + selection = self.files_view.selectionModel() + index = selection.currentIndex() + if not index.isValid(): + return + + return index.data(self.files_model.FilePathRole) + + def on_open_pressed(self): + path = self._get_selected_filepath() + if not path: + print("No file selected to open..") + return + + self.open_file(path) + + def on_browse_pressed(self): + ext_filter = "Work File (*{0})".format( + " *".join(self.host.file_extensions()) + ) + kwargs = { + "caption": "Work Files", + "filter": ext_filter + } + if Qt.__binding__ in ("PySide", "PySide2"): + kwargs["dir"] = self.root + else: + kwargs["directory"] = self.root + + work_file = QtWidgets.QFileDialog.getOpenFileName(**kwargs)[0] + if work_file: + self.open_file(work_file) + + def on_save_as_pressed(self): + work_file = self.get_filename() + if not work_file: + return + + # Initialize work directory if it has not been initialized before + if not os.path.exists(self.root): + log.debug("Initializing Work Directory: %s", self.root) + self.initialize_work_directory() + if not os.path.exists(self.root): + # Failed to initialize Work Directory + log.error( + "Failed to initialize Work Directory: {}".format(self.root) + ) + return + + file_path = os.path.join(self.root, work_file) + + self._enter_session() # Make sure we are in the right session + self.host.save_file(file_path) + + self.set_asset_task(self._asset, self._task) + + self.workfile_created.emit(file_path) + + self.refresh() + + def on_file_select(self): + self.file_selected.emit(self._get_selected_filepath()) + + def initialize_work_directory(self): + """Initialize Work Directory. + + This is used when the Work Directory does not exist yet. + + This finds the current AVALON_APP_NAME and tries to triggers its + `.toml` initialization step. Note that this will only be valid + whenever `AVALON_APP_NAME` is actually set in the current session. + + """ + + # Inputs (from the switched session and running app) + session = api.Session.copy() + changes = pipeline.compute_session_changes( + session, + asset=self._asset, + task=self._task + ) + session.update(changes) + + # Prepare documents to get workdir data + project_doc = io.find_one({"type": "project"}) + asset_doc = io.find_one( + { + "type": "asset", + "name": session["AVALON_ASSET"] + } + ) + task_name = session["AVALON_TASK"] + host_name = session["AVALON_APP"] + + # Get workdir from collected documents + workdir = get_workdir(project_doc, asset_doc, task_name, host_name) + # Create workdir if does not exist yet + if not os.path.exists(workdir): + os.makedirs(workdir) + + # Force a full to the asset as opposed to just self.refresh() so + # that it will actually check again whether the Work directory exists + self.set_asset_task(self._asset, self._task) + + def refresh(self): + """Refresh listed files for current selection in the interface""" + self.files_model.refresh() + + if self.auto_select_latest_modified: + tools_lib.schedule(self._select_last_modified_file, 100) + + def on_context_menu(self, point): + index = self.files_view.indexAt(point) + if not index.isValid(): + return + + is_enabled = index.data(FilesModel.IsEnabled) + if not is_enabled: + return + + menu = QtWidgets.QMenu(self) + + # Duplicate + action = QtWidgets.QAction("Duplicate", menu) + tip = "Duplicate selected file." + action.setToolTip(tip) + action.setStatusTip(tip) + action.triggered.connect(self.on_duplicate_pressed) + menu.addAction(action) + + # Show the context action menu + global_point = self.files_view.mapToGlobal(point) + action = menu.exec_(global_point) + if not action: + return + + def _select_last_modified_file(self): + """Utility function to select the file with latest date modified""" + role = self.files_model.DateModifiedRole + model = self.files_view.model() + + highest_index = None + highest = 0 + for row in range(model.rowCount()): + index = model.index(row, 0, parent=QtCore.QModelIndex()) + if not index.isValid(): + continue + + modified = index.data(role) + if modified is not None and modified > highest: + highest_index = index + highest = modified + + if highest_index: + self.files_view.setCurrentIndex(highest_index) + + +class SidePanelWidget(QtWidgets.QWidget): + save_clicked = QtCore.Signal() + + def __init__(self, parent=None): + super(SidePanelWidget, self).__init__(parent) + + details_label = QtWidgets.QLabel("Details", self) + details_input = QtWidgets.QPlainTextEdit(self) + details_input.setReadOnly(True) + + note_label = QtWidgets.QLabel("Artist note", self) + note_input = QtWidgets.QPlainTextEdit(self) + btn_note_save = QtWidgets.QPushButton("Save note", self) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(details_label, 0) + main_layout.addWidget(details_input, 0) + main_layout.addWidget(note_label, 0) + main_layout.addWidget(note_input, 1) + main_layout.addWidget(btn_note_save, alignment=QtCore.Qt.AlignRight) + + note_input.textChanged.connect(self.on_note_change) + btn_note_save.clicked.connect(self.on_save_click) + + self.details_input = details_input + self.note_input = note_input + self.btn_note_save = btn_note_save + + self._orig_note = "" + self._workfile_doc = None + + def on_note_change(self): + text = self.note_input.toPlainText() + self.btn_note_save.setEnabled(self._orig_note != text) + + def on_save_click(self): + self._orig_note = self.note_input.toPlainText() + self.on_note_change() + self.save_clicked.emit() + + def set_context(self, asset_doc, task_name, filepath, workfile_doc): + # Check if asset, task and file are selected + # NOTE workfile document is not requirement + enabled = bool(asset_doc) and bool(task_name) and bool(filepath) + + self.details_input.setEnabled(enabled) + self.note_input.setEnabled(enabled) + self.btn_note_save.setEnabled(enabled) + + # Make sure workfile doc is overriden + self._workfile_doc = workfile_doc + # Disable inputs and remove texts if any required arguments are missing + if not enabled: + self._orig_note = "" + self.details_input.setPlainText("") + self.note_input.setPlainText("") + return + + orig_note = "" + if workfile_doc: + orig_note = workfile_doc["data"].get("note") or orig_note + + self._orig_note = orig_note + self.note_input.setPlainText(orig_note) + # Set as empty string + self.details_input.setPlainText("") + + filestat = os.stat(filepath) + size_ending_mapping = { + "KB": 1024 ** 1, + "MB": 1024 ** 2, + "GB": 1024 ** 3 + } + size = filestat.st_size + ending = "B" + for _ending, _size in size_ending_mapping.items(): + if filestat.st_size < _size: + break + size = filestat.st_size / _size + ending = _ending + + # Append html string + datetime_format = "%b %d %Y %H:%M:%S" + creation_time = datetime.datetime.fromtimestamp(filestat.st_ctime) + modification_time = datetime.datetime.fromtimestamp(filestat.st_mtime) + lines = ( + "Size:", + "{:.2f} {}".format(size, ending), + "Created:", + creation_time.strftime(datetime_format), + "Modified:", + modification_time.strftime(datetime_format) + ) + self.details_input.appendHtml("
".join(lines)) + + def get_workfile_data(self): + data = { + "note": self.note_input.toPlainText() + } + return self._workfile_doc, data + + +class Window(QtWidgets.QMainWindow): + """Work Files Window""" + title = "Work Files" + + def __init__(self, parent=None): + super(Window, self).__init__(parent=parent) + self.setWindowTitle(self.title) + self.setWindowFlags(QtCore.Qt.Window | QtCore.Qt.WindowCloseButtonHint) + + # Create pages widget and set it as central widget + pages_widget = QtWidgets.QStackedWidget(self) + self.setCentralWidget(pages_widget) + + home_page_widget = QtWidgets.QWidget(pages_widget) + home_body_widget = QtWidgets.QWidget(home_page_widget) + + assets_widget = AssetWidget(io, parent=home_body_widget) + tasks_widget = TasksWidget(home_body_widget) + files_widget = FilesWidget(home_body_widget) + side_panel = SidePanelWidget(home_body_widget) + + pages_widget.addWidget(home_page_widget) + + # Build home + home_page_layout = QtWidgets.QVBoxLayout(home_page_widget) + home_page_layout.addWidget(home_body_widget) + + # Build home - body + body_layout = QtWidgets.QVBoxLayout(home_body_widget) + split_widget = QtWidgets.QSplitter(home_body_widget) + split_widget.addWidget(assets_widget) + split_widget.addWidget(tasks_widget) + split_widget.addWidget(files_widget) + split_widget.addWidget(side_panel) + split_widget.setStretchFactor(0, 1) + split_widget.setStretchFactor(1, 1) + split_widget.setStretchFactor(2, 3) + split_widget.setStretchFactor(3, 1) + body_layout.addWidget(split_widget) + + # Add top margin for tasks to align it visually with files as + # the files widget has a filter field which tasks does not. + tasks_widget.setContentsMargins(0, 32, 0, 0) + + # Connect signals + assets_widget.current_changed.connect(self.on_asset_changed) + tasks_widget.task_changed.connect(self.on_task_changed) + files_widget.file_selected.connect(self.on_file_select) + files_widget.workfile_created.connect(self.on_workfile_create) + side_panel.save_clicked.connect(self.on_side_panel_save) + + self.home_page_widget = home_page_widget + self.pages_widget = pages_widget + self.home_body_widget = home_body_widget + self.split_widget = split_widget + + self.assets_widget = assets_widget + self.tasks_widget = tasks_widget + self.files_widget = files_widget + self.side_panel = side_panel + + self.refresh() + + # Force focus on the open button by default, required for Houdini. + files_widget.btn_open.setFocus() + + self.resize(1000, 600) + + 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 accidently perform Maya commands + whilst trying to name an instance. + + """ + + def on_task_changed(self): + # Since we query the disk give it slightly more delay + tools_lib.schedule(self._on_task_changed, 100, channel="mongo") + + def on_asset_changed(self): + tools_lib.schedule(self._on_asset_changed, 50, channel="mongo") + + def on_file_select(self, filepath): + asset_docs = self.assets_widget.get_selected_assets() + asset_doc = None + if asset_docs: + asset_doc = asset_docs[0] + + task_name = self.tasks_widget.get_current_task() + + workfile_doc = None + if asset_doc and task_name and filepath: + filename = os.path.split(filepath)[1] + workfile_doc = get_workfile_doc( + asset_doc["_id"], task_name, filename, io + ) + self.side_panel.set_context( + asset_doc, task_name, filepath, workfile_doc + ) + + def on_workfile_create(self, filepath): + self._create_workfile_doc(filepath) + + def on_side_panel_save(self): + workfile_doc, data = self.side_panel.get_workfile_data() + if not workfile_doc: + filepath = self.files_widget._get_selected_filepath() + self._create_workfile_doc(filepath, force=True) + workfile_doc = self._get_current_workfile_doc() + + save_workfile_data_to_doc(workfile_doc, data, io) + + def _get_current_workfile_doc(self, filepath=None): + if filepath is None: + filepath = self.files_widget._get_selected_filepath() + task_name = self.tasks_widget.get_current_task() + asset_docs = self.assets_widget.get_selected_assets() + if not task_name or not asset_docs or not filepath: + return + + asset_doc = asset_docs[0] + filename = os.path.split(filepath)[1] + return get_workfile_doc( + asset_doc["_id"], task_name, filename, io + ) + + def _create_workfile_doc(self, filepath, force=False): + workfile_doc = None + if not force: + workfile_doc = self._get_current_workfile_doc(filepath) + + if not workfile_doc: + workdir, filename = os.path.split(filepath) + asset_docs = self.assets_widget.get_selected_assets() + asset_doc = asset_docs[0] + task_name = self.tasks_widget.get_current_task() + create_workfile_doc(asset_doc, task_name, filename, workdir, io) + + def set_context(self, context): + if "asset" in context: + asset = context["asset"] + asset_document = io.find_one( + { + "name": asset, + "type": "asset" + }, + { + "data.tasks": 1 + } + ) + + # Select the asset + self.assets_widget.select_assets([asset], expand=True) + + # Force a refresh on Tasks? + self.tasks_widget.set_asset(asset_document) + + if "task" in context: + self.tasks_widget.select_task(context["task"]) + + def refresh(self): + # Refresh asset widget + self.assets_widget.refresh() + + self._on_task_changed() + + def _on_asset_changed(self): + asset = self.assets_widget.get_selected_assets() or None + + if not asset: + # Force disable the other widgets if no + # active selection + self.tasks_widget.setEnabled(False) + self.files_widget.setEnabled(False) + else: + asset = asset[0] + self.tasks_widget.setEnabled(True) + + self.tasks_widget.set_asset(asset) + + def _on_task_changed(self): + asset = self.assets_widget.get_selected_assets() or None + if asset is not None: + asset = asset[0] + task = self.tasks_widget.get_current_task() + + self.tasks_widget.setEnabled(bool(asset)) + + self.files_widget.setEnabled(all([bool(task), bool(asset)])) + self.files_widget.set_asset_task(asset, task) + self.files_widget.refresh() + + +def validate_host_requirements(host): + if host is None: + raise RuntimeError("No registered host.") + + # Verify the host has implemented the api for Work Files + required = [ + "open_file", + "save_file", + "current_file", + "has_unsaved_changes", + "work_root", + "file_extensions", + ] + missing = [] + for name in required: + if not hasattr(host, name): + missing.append(name) + if missing: + raise RuntimeError( + "Host is missing required Work Files interfaces: " + "%s (host: %s)" % (", ".join(missing), host) + ) + return True + + +def show(root=None, debug=False, parent=None, use_context=True, save=True): + """Show Work Files GUI""" + # todo: remove `root` argument to show() + + try: + module.window.close() + del(module.window) + except (AttributeError, RuntimeError): + pass + + host = api.registered_host() + validate_host_requirements(host) + + if debug: + api.Session["AVALON_ASSET"] = "Mock" + api.Session["AVALON_TASK"] = "Testing" + + with tools_lib.application(): + window = Window(parent=parent) + window.refresh() + + if use_context: + context = { + "asset": api.Session["AVALON_ASSET"], + "silo": api.Session["AVALON_SILO"], + "task": api.Session["AVALON_TASK"] + } + window.set_context(context) + + window.files_widget.btn_save.setEnabled(save) + + window.show() + window.setStyleSheet(style.load_stylesheet()) + + module.window = window + + # Pull window to the front. + module.window.raise_() + module.window.activateWindow() diff --git a/pype/tools/workfiles/model.py b/pype/tools/workfiles/model.py new file mode 100644 index 0000000000..368988fd4e --- /dev/null +++ b/pype/tools/workfiles/model.py @@ -0,0 +1,153 @@ +import os +import logging + +from Qt import QtCore + +from avalon import style +from avalon.vendor import qtawesome +from avalon.tools.models import TreeModel, Item + +log = logging.getLogger(__name__) + + +class FilesModel(TreeModel): + """Model listing files with specified extensions in a root folder""" + Columns = ["filename", "date"] + + FileNameRole = QtCore.Qt.UserRole + 2 + DateModifiedRole = QtCore.Qt.UserRole + 3 + FilePathRole = QtCore.Qt.UserRole + 4 + IsEnabled = QtCore.Qt.UserRole + 5 + + def __init__(self, file_extensions, parent=None): + super(FilesModel, self).__init__(parent=parent) + + self._root = None + self._file_extensions = file_extensions + self._icons = { + "file": qtawesome.icon("fa.file-o", color=style.colors.default) + } + + def set_root(self, root): + self._root = root + self.refresh() + + def _add_empty(self): + item = Item() + item.update({ + # Put a display message in 'filename' + "filename": "No files found.", + # Not-selectable + "enabled": False, + "date": None, + "filepath": None + }) + + self.add_child(item) + + def refresh(self): + self.clear() + self.beginResetModel() + + root = self._root + + if not root: + self.endResetModel() + return + + if not os.path.exists(root): + # Add Work Area does not exist placeholder + log.debug("Work Area does not exist: %s", root) + message = "Work Area does not exist. Use Save As to create it." + item = Item({ + "filename": message, + "date": None, + "filepath": None, + "enabled": False, + "icon": qtawesome.icon("fa.times", color=style.colors.mid) + }) + self.add_child(item) + self.endResetModel() + return + + extensions = self._file_extensions + + for filename in os.listdir(root): + path = os.path.join(root, filename) + if os.path.isdir(path): + continue + + ext = os.path.splitext(filename)[1] + if extensions and ext not in extensions: + continue + + modified = os.path.getmtime(path) + + item = Item({ + "filename": filename, + "date": modified, + "filepath": path + }) + + self.add_child(item) + + if self.rowCount() == 0: + self._add_empty() + + self.endResetModel() + + def has_filenames(self): + for item in self._root_item.children(): + if item.get("enabled", True): + return True + return False + + def rowCount(self, parent=None): + if parent is None or not parent.isValid(): + parent_item = self._root_item + else: + parent_item = parent.internalPointer() + return parent_item.childCount() + + def data(self, index, role): + if not index.isValid(): + return + + if role == QtCore.Qt.DecorationRole: + # Add icon to filename column + item = index.internalPointer() + if index.column() == 0: + if item["filepath"]: + return self._icons["file"] + return item.get("icon", None) + + if role == self.FileNameRole: + item = index.internalPointer() + return item["filename"] + + if role == self.DateModifiedRole: + item = index.internalPointer() + return item["date"] + + if role == self.FilePathRole: + item = index.internalPointer() + return item["filepath"] + + if role == self.IsEnabled: + item = index.internalPointer() + return item.get("enabled", True) + + return super(FilesModel, self).data(index, role) + + def headerData(self, section, orientation, role): + # Show nice labels in the header + if ( + role == QtCore.Qt.DisplayRole + and orientation == QtCore.Qt.Horizontal + ): + if section == 0: + return "Name" + elif section == 1: + return "Date modified" + + return super(FilesModel, self).headerData(section, orientation, role) diff --git a/pype/tools/workfiles/view.py b/pype/tools/workfiles/view.py new file mode 100644 index 0000000000..8e3993e4c7 --- /dev/null +++ b/pype/tools/workfiles/view.py @@ -0,0 +1,15 @@ +from Qt import QtWidgets, QtCore + + +class FilesView(QtWidgets.QTreeView): + doubleClickedLeft = QtCore.Signal() + doubleClickedRight = QtCore.Signal() + + def mouseDoubleClickEvent(self, event): + if event.button() == QtCore.Qt.LeftButton: + self.doubleClickedLeft.emit() + + elif event.button() == QtCore.Qt.RightButton: + self.doubleClickedRight.emit() + + return super(FilesView, self).mouseDoubleClickEvent(event) diff --git a/schema/workfile-1.0.json b/schema/workfile-1.0.json new file mode 100644 index 0000000000..15bfdc6ff7 --- /dev/null +++ b/schema/workfile-1.0.json @@ -0,0 +1,52 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + + "title": "pype:workfile-1.0", + "description": "Workfile additional information.", + + "type": "object", + + "additionalProperties": true, + + "required": [ + "schema", + "type", + "filename", + "task_name", + "parent" + ], + + "properties": { + "schema": { + "description": "Schema identifier for payload", + "type": "string", + "enum": ["pype:workfile-1.0"], + "example": "pype:workfile-1.0" + }, + "type": { + "description": "The type of document", + "type": "string", + "enum": ["workfile"], + "example": "workfile" + }, + "parent": { + "description": "Unique identifier to parent document", + "example": "592c33475f8c1b064c4d1696" + }, + "filename": { + "description": "Workfile's filename", + "type": "string", + "example": "kuba_each_case_Alpaca_01_animation_v001.ma" + }, + "task_name": { + "description": "Task name", + "type": "string", + "example": "animation" + }, + "data": { + "description": "Document metadata", + "type": "object", + "example": {"key": "value"} + } + } +}