From 6e95f79dcf42c7ebb7df7f3970d21c68eb9e8bd6 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 5 Jan 2021 19:23:57 +0100 Subject: [PATCH 01/32] copied workfiles tool to pype tools --- pype/tools/workfiles/README.md | 143 +++++ pype/tools/workfiles/__init__.py | 7 + pype/tools/workfiles/app.py | 893 +++++++++++++++++++++++++++++++ pype/tools/workfiles/model.py | 134 +++++ pype/tools/workfiles/view.py | 16 + 5 files changed, 1193 insertions(+) create mode 100644 pype/tools/workfiles/README.md create mode 100644 pype/tools/workfiles/__init__.py create mode 100644 pype/tools/workfiles/app.py create mode 100644 pype/tools/workfiles/model.py create mode 100644 pype/tools/workfiles/view.py 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..4f49011584 --- /dev/null +++ b/pype/tools/workfiles/__init__.py @@ -0,0 +1,7 @@ +from .app import ( + show +) + +__all__ = [ + "show" +] diff --git a/pype/tools/workfiles/app.py b/pype/tools/workfiles/app.py new file mode 100644 index 0000000000..d7e27c7358 --- /dev/null +++ b/pype/tools/workfiles/app.py @@ -0,0 +1,893 @@ +import sys +import os +import copy +import getpass +import shutil +import logging + +from ...vendor import Qt +from ...vendor.Qt import QtWidgets, QtCore +from ... import style, io, api, pipeline + +from .. import lib as tools_lib +from ..widgets import AssetWidget +from ..models import TasksModel +from ..delegates import PrettyTimeDelegate + +from .model import FilesModel +from .view import FilesView + +from pype.api import Anatomy + +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, 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 session is None: + # Fallback to active session + session = api.Session + + # Set work file data for template formatting + project = io.find_one({ + "type": "project" + }) + self.data = { + "project": { + "name": project["name"], + "code": project["data"].get("code") + }, + "asset": session["AVALON_ASSET"], + "task": session["AVALON_TASK"], + "version": 1, + "user": getpass.getuser(), + "comment": "" + } + + # Define work files template + anatomy = Anatomy(project["name"]) + self.template = anatomy.templates["work"]["file"] + + self.widgets = { + "preview": QtWidgets.QLabel("Preview filename"), + "comment": QtWidgets.QLineEdit(), + "version": QtWidgets.QWidget(), + "versionValue": QtWidgets.QSpinBox(), + "versionCheck": QtWidgets.QCheckBox("Next Available Version"), + "inputs": QtWidgets.QWidget(), + "buttons": QtWidgets.QWidget(), + "okButton": QtWidgets.QPushButton("Ok"), + "cancelButton": QtWidgets.QPushButton("Cancel") + } + + # Build version + self.widgets["versionValue"].setMinimum(1) + self.widgets["versionValue"].setMaximum(9999) + self.widgets["versionCheck"].setCheckState(QtCore.Qt.CheckState(2)) + layout = QtWidgets.QHBoxLayout(self.widgets["version"]) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.widgets["versionValue"]) + layout.addWidget(self.widgets["versionCheck"]) + + # Build buttons + layout = QtWidgets.QHBoxLayout(self.widgets["buttons"]) + layout.addWidget(self.widgets["okButton"]) + layout.addWidget(self.widgets["cancelButton"]) + + # Build inputs + layout = QtWidgets.QFormLayout(self.widgets["inputs"]) + layout.addRow("Version:", self.widgets["version"]) + layout.addRow("Comment:", self.widgets["comment"]) + layout.addRow("Preview:", self.widgets["preview"]) + + # Build layout + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(self.widgets["inputs"]) + layout.addWidget(self.widgets["buttons"]) + + self.widgets["versionValue"].valueChanged.connect( + self.on_version_spinbox_changed + ) + self.widgets["versionCheck"].stateChanged.connect( + self.on_version_checkbox_changed + ) + self.widgets["comment"].textChanged.connect(self.on_comment_changed) + self.widgets["okButton"].pressed.connect(self.on_ok_pressed) + self.widgets["cancelButton"].pressed.connect(self.on_cancel_pressed) + + # Allow "Enter" key to accept the save. + self.widgets["okButton"].setDefault(True) + + # Force default focus to comment, some hosts didn't automatically + # apply focus to this line edit (e.g. Houdini) + self.widgets["comment"].setFocus() + + 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_ok_pressed(self): + self.result = self.work_file.replace("\\", "/") + self.close() + + def on_cancel_pressed(self): + self.close() + + def get_result(self): + return self.result + + def get_work_file(self, template=None): + data = copy.deepcopy(self.data) + template = template or self.template + + # 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: + # Fall back to the first extension supported for this host. + extension = self.host.file_extensions()[0] + + data["ext"] = extension + + if not data["comment"]: + data.pop("comment", None) + + return api.format_template_with_optional_keys(data, template) + + def refresh(self): + # Since the version can be padded with "{version:0>4}" we only search + # for "{version". + if "{version" not in self.template: + # todo: hide the full row + self.widgets["version"].setVisible(False) + + # Build comment + if "{comment}" not in self.template: + # todo: hide the full row + self.widgets["comment"].setVisible(False) + + if self.widgets["versionCheck"].isChecked(): + self.widgets["versionValue"].setEnabled(False) + + extensions = self.host.file_extensions() + data = copy.deepcopy(self.data) + template = str(self.template) + + if not data["comment"]: + data.pop("comment", None) + + version = api.last_workfile_with_version( + self.root, template, data, extensions + )[1] + + if version is None: + version = 1 + else: + version += 1 + + self.data["version"] = version + + # safety check + path = os.path.join(self.root, self.get_work_file()) + assert not os.path.exists(path), \ + "This is a bug, file exists: %s" % path + + else: + self.widgets["versionValue"].setEnabled(True) + self.data["version"] = self.widgets["versionValue"].value() + + self.work_file = self.get_work_file() + + preview = self.widgets["preview"] + ok = self.widgets["okButton"] + preview.setText( + "{0}".format(self.work_file) + ) + if os.path.exists(os.path.join(self.root, self.work_file)): + preview.setText( + "Cannot create \"{0}\" because file exists!" + "".format(self.work_file) + ) + ok.setEnabled(False) + else: + ok.setEnabled(True) + + +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.""" + def __init__(self, parent=None): + super(FilesWidget, self).__init__(parent=parent) + + # Setup + self._asset = None + self._task = None + 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 + + widgets = { + "filter": QtWidgets.QLineEdit(), + "list": FilesView(), + "open": QtWidgets.QPushButton("Open"), + "browse": QtWidgets.QPushButton("Browse"), + "save": QtWidgets.QPushButton("Save As") + } + + delegates = { + "time": PrettyTimeDelegate() + } + + # Create the files model + extensions = set(self.host.file_extensions()) + self.model = FilesModel(file_extensions=extensions) + self.proxy = QtCore.QSortFilterProxyModel() + self.proxy.setSourceModel(self.model) + self.proxy.setDynamicSortFilter(True) + self.proxy.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) + + # Set up the file list tree view + widgets["list"].setModel(self.proxy) + widgets["list"].setSortingEnabled(True) + widgets["list"].setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + # Date modified delegate + widgets["list"].setItemDelegateForColumn(1, delegates["time"]) + widgets["list"].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. + widgets["list"].setColumnWidth(0, 330) + + widgets["filter"].textChanged.connect(self.proxy.setFilterFixedString) + widgets["filter"].setPlaceholderText("Filter files..") + + # Home Page + # Build buttons widget for files widget + buttons = QtWidgets.QWidget() + layout = QtWidgets.QHBoxLayout(buttons) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(widgets["open"]) + layout.addWidget(widgets["browse"]) + layout.addWidget(widgets["save"]) + + # Build files widgets for home page + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(widgets["filter"]) + layout.addWidget(widgets["list"]) + layout.addWidget(buttons) + + widgets["list"].doubleClickedLeft.connect(self.on_open_pressed) + widgets["list"].customContextMenuRequested.connect( + self.on_context_menu + ) + widgets["open"].pressed.connect(self.on_open_pressed) + widgets["browse"].pressed.connect(self.on_browse_pressed) + widgets["save"].pressed.connect(self.on_save_as_pressed) + + self.widgets = widgets + self.delegates = delegates + + 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) + + exists = os.path.exists(self.root) + self.widgets["browse"].setEnabled(exists) + self.widgets["open"].setEnabled(exists) + self.model.set_root(self.root) + else: + self.model.set_root(None) + + 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 + + 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) + + else: + # Don't save, continue to open file + pass + + self._enter_session() + host.open_file(filepath) + self.window().close() + + def save_changes_prompt(self): + self._messagebox = QtWidgets.QMessageBox() + messagebox = self._messagebox + + 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 + else: + 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, + 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.refresh() + + def _get_selected_filepath(self): + """Return current filepath selected in view""" + model = self.model + view = self.widgets["list"] + selection = view.selectionModel() + index = selection.currentIndex() + if not index.isValid(): + return + + return index.data(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): + + filter = " *".join(self.host.file_extensions()) + filter = "Work File (*{0})".format(filter) + kwargs = { + "caption": "Work Files", + "filter": filter + } + if Qt.__binding__ in ("PySide", "PySide2"): + kwargs["dir"] = self.root + else: + kwargs["directory"] = self.root + work_file = QtWidgets.QFileDialog.getOpenFileName(**kwargs)[0] + + if not work_file: + return + + 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: " + "%s", 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.refresh() + + 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) + + # Find the application definition + app_name = os.environ.get("AVALON_APP_NAME") + if not app_name: + log.error("No AVALON_APP_NAME session variable is set. " + "Unable to initialize app Work Directory.") + return + + app_definition = pipeline.lib.get_application(app_name) + App = type("app_%s" % app_name, + (pipeline.Application,), + {"config": app_definition.copy()}) + + # Initialize within the new session's environment + app = App() + env = app.environ(session) + app.initialize(env) + + # 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.model.refresh() + + if self.auto_select_latest_modified: + tools_lib.schedule(self._select_last_modified_file, + 100) + + def on_context_menu(self, point): + + view = self.widgets["list"] + index = 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 = 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.model.DateModifiedRole + view = self.widgets["list"] + model = 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: + view.setCurrentIndex(highest_index) + + +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) + + pages = { + "home": QtWidgets.QWidget() + } + + widgets = { + "pages": QtWidgets.QStackedWidget(), + "body": QtWidgets.QWidget(), + "assets": AssetWidget(io), + "tasks": TasksWidget(), + "files": FilesWidget() + } + + self.setCentralWidget(widgets["pages"]) + widgets["pages"].addWidget(pages["home"]) + + # Build home + layout = QtWidgets.QVBoxLayout(pages["home"]) + layout.addWidget(widgets["body"]) + + # Build home - body + layout = QtWidgets.QVBoxLayout(widgets["body"]) + split = QtWidgets.QSplitter() + split.addWidget(widgets["assets"]) + split.addWidget(widgets["tasks"]) + split.addWidget(widgets["files"]) + split.setStretchFactor(0, 1) + split.setStretchFactor(1, 1) + split.setStretchFactor(2, 3) + layout.addWidget(split) + + # Add top margin for tasks to align it visually with files as + # the files widget has a filter field which tasks does not. + widgets["tasks"].setContentsMargins(0, 32, 0, 0) + + # Connect signals + widgets["assets"].current_changed.connect(self.on_asset_changed) + widgets["tasks"].task_changed.connect(self.on_task_changed) + + self.widgets = widgets + self.refresh() + + # Force focus on the open button by default, required for Houdini. + self.widgets["files"].widgets["open"].setFocus() + + self.resize(900, 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 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.widgets["assets"].select_assets([asset], expand=True) + + # Force a refresh on Tasks? + self.widgets["tasks"].set_asset(asset_document) + + if "task" in context: + self.widgets["tasks"].select_task(context["task"]) + + def refresh(self): + + # Refresh asset widget + self.widgets["assets"].refresh() + + self._on_task_changed() + + def _on_asset_changed(self): + asset = self.widgets["assets"].get_selected_assets() or None + + if not asset: + # Force disable the other widgets if no + # active selection + self.widgets["tasks"].setEnabled(False) + self.widgets["files"].setEnabled(False) + else: + asset = asset[0] + self.widgets["tasks"].setEnabled(True) + + self.widgets["tasks"].set_asset(asset) + + def _on_task_changed(self): + + asset = self.widgets["assets"].get_selected_assets() or None + if asset is not None: + asset = asset[0] + task = self.widgets["tasks"].get_current_task() + + self.widgets["tasks"].setEnabled(bool(asset)) + self.widgets["files"].setEnabled(all([bool(task), bool(asset)])) + + files = self.widgets["files"] + files.set_asset_task(asset, task) + files.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.widgets["files"].widgets["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..484297bc05 --- /dev/null +++ b/pype/tools/workfiles/model.py @@ -0,0 +1,134 @@ +import os +import logging + +from ... import style +from ...vendor.Qt import QtCore +from ...vendor import qtawesome + +from ..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, + "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 f in os.listdir(root): + path = os.path.join(root, f) + if os.path.isdir(path): + continue + + if extensions and os.path.splitext(f)[1] not in extensions: + continue + + modified = os.path.getmtime(path) + + item = Item({ + "filename": f, + "date": modified, + "filepath": path + }) + + self.add_child(item) + + self.endResetModel() + + 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"] + else: + 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..624bfd18e0 --- /dev/null +++ b/pype/tools/workfiles/view.py @@ -0,0 +1,16 @@ +from ...vendor.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) From 433cfbd458fa73695d09e5c0f9172469bdac5350 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 5 Jan 2021 19:30:38 +0100 Subject: [PATCH 02/32] import modifications --- pype/tools/workfiles/app.py | 14 +++++++------- pype/tools/workfiles/model.py | 30 ++++++++++++++---------------- pype/tools/workfiles/view.py | 3 +-- 3 files changed, 22 insertions(+), 25 deletions(-) diff --git a/pype/tools/workfiles/app.py b/pype/tools/workfiles/app.py index d7e27c7358..cc47d9d2c4 100644 --- a/pype/tools/workfiles/app.py +++ b/pype/tools/workfiles/app.py @@ -5,14 +5,14 @@ import getpass import shutil import logging -from ...vendor import Qt -from ...vendor.Qt import QtWidgets, QtCore -from ... import style, io, api, pipeline +import Qt +from Qt import QtWidgets, QtCore +from avalon import style, io, api, pipeline -from .. import lib as tools_lib -from ..widgets import AssetWidget -from ..models import TasksModel -from ..delegates import PrettyTimeDelegate +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 diff --git a/pype/tools/workfiles/model.py b/pype/tools/workfiles/model.py index 484297bc05..ee8dfbadd9 100644 --- a/pype/tools/workfiles/model.py +++ b/pype/tools/workfiles/model.py @@ -1,11 +1,11 @@ import os import logging -from ... import style -from ...vendor.Qt import QtCore -from ...vendor import qtawesome +from avalon import style +from Qt import QtCore +from avalon.vendor import qtawesome -from ..models import TreeModel, Item +from avalon.tools.models import TreeModel, Item log = logging.getLogger(__name__) @@ -24,15 +24,15 @@ class FilesModel(TreeModel): self._root = None self._file_extensions = file_extensions - self._icons = {"file": qtawesome.icon("fa.file-o", - color=style.colors.default)} + 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' @@ -45,7 +45,6 @@ class FilesModel(TreeModel): self.add_child(item) def refresh(self): - self.clear() self.beginResetModel() @@ -64,8 +63,7 @@ class FilesModel(TreeModel): "date": None, "filepath": None, "enabled": False, - "icon": qtawesome.icon("fa.times", - color=style.colors.mid) + "icon": qtawesome.icon("fa.times", color=style.colors.mid) }) self.add_child(item) self.endResetModel() @@ -94,7 +92,6 @@ class FilesModel(TreeModel): self.endResetModel() def data(self, index, role): - if not index.isValid(): return @@ -104,8 +101,8 @@ class FilesModel(TreeModel): if index.column() == 0: if item["filepath"]: return self._icons["file"] - else: - return item.get("icon", None) + return item.get("icon", None) + if role == self.FileNameRole: item = index.internalPointer() return item["filename"] @@ -122,10 +119,11 @@ class FilesModel(TreeModel): 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 ( + role == QtCore.Qt.DisplayRole + and orientation == QtCore.Qt.Horizontal + ): if section == 0: return "Name" elif section == 1: diff --git a/pype/tools/workfiles/view.py b/pype/tools/workfiles/view.py index 624bfd18e0..8e3993e4c7 100644 --- a/pype/tools/workfiles/view.py +++ b/pype/tools/workfiles/view.py @@ -1,8 +1,7 @@ -from ...vendor.Qt import QtWidgets, QtCore +from Qt import QtWidgets, QtCore class FilesView(QtWidgets.QTreeView): - doubleClickedLeft = QtCore.Signal() doubleClickedRight = QtCore.Signal() From 5b6b708090e9ac40887cd073aa08c71911af4e29 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 6 Jan 2021 11:08:52 +0100 Subject: [PATCH 03/32] extracted get workdir functions to avalon_context in pype.lib --- pype/lib/__init__.py | 6 +++ pype/lib/avalon_context.py | 88 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/pype/lib/__init__.py b/pype/lib/__init__.py index 09cc998b7c..0d5dfa4373 100644 --- a/pype/lib/__init__.py +++ b/pype/lib/__init__.py @@ -39,6 +39,9 @@ from .avalon_context import ( get_hierarchy, get_linked_assets, get_latest_version, + get_workdir_data, + get_workdir, + get_workdir_with_workdir_data, BuildWorkfile ) @@ -96,6 +99,9 @@ __all__ = [ "get_hierarchy", "get_linked_assets", "get_latest_version", + "get_workdir_data", + "get_workdir", + "get_workdir_with_workdir_data", "BuildWorkfile", "PypeHook", diff --git a/pype/lib/avalon_context.py b/pype/lib/avalon_context.py index 3a18e956d9..da80002b07 100644 --- a/pype/lib/avalon_context.py +++ b/pype/lib/avalon_context.py @@ -6,6 +6,7 @@ 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 +247,93 @@ 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 +): + """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. + + Returns: + str: 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) + anatomy_filled = anatomy.format(workdir_data) + workdir = os.path.normpath(anatomy_filled["work"]["folder"]) + return workdir + + +def get_workdir(project_doc, asset_doc, task_name, host_name, anatomy=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. + """ + if not anatomy: + anatomy = Anatomy(project_doc["name"]) + + workdir_data = get_workdir_data( + project_doc, asset_doc, task_name, host_name + ) + return get_workdir_with_workdir_data(workdir_data, anatomy) + + class BuildWorkfile: """Wrapper for build workfile process. From 6e328609ca427cdc319fd017a27cbe985b207f9c Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 6 Jan 2021 11:09:47 +0100 Subject: [PATCH 04/32] use extracted workdir function in prelaunch hook --- pype/hooks/global/pre_global_host_data.py | 26 ++++++----------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/pype/hooks/global/pre_global_host_data.py b/pype/hooks/global/pre_global_host_data.py index 4910d08010..199538e9bb 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,8 +142,8 @@ 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 @@ -149,8 +151,7 @@ class GlobalHostDataHook(PreLaunchHook): 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) @@ -180,21 +181,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. From 489cad80fd7bcaf03bfc1356dbfa1fc33f404e43 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 6 Jan 2021 11:18:37 +0100 Subject: [PATCH 05/32] workfiles tool handle creating of workdir folder --- pype/tools/workfiles/app.py | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/pype/tools/workfiles/app.py b/pype/tools/workfiles/app.py index cc47d9d2c4..e402636b53 100644 --- a/pype/tools/workfiles/app.py +++ b/pype/tools/workfiles/app.py @@ -17,7 +17,10 @@ from avalon.tools.delegates import PrettyTimeDelegate from .model import FilesModel from .view import FilesView -from pype.api import Anatomy +from pype.lib import ( + Anatomy, + get_workdir +) log = logging.getLogger(__name__) @@ -618,22 +621,22 @@ class FilesWidget(QtWidgets.QWidget): task=self._task) session.update(changes) - # Find the application definition - app_name = os.environ.get("AVALON_APP_NAME") - if not app_name: - log.error("No AVALON_APP_NAME session variable is set. " - "Unable to initialize app Work Directory.") - return + # 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"] - app_definition = pipeline.lib.get_application(app_name) - App = type("app_%s" % app_name, - (pipeline.Application,), - {"config": app_definition.copy()}) - - # Initialize within the new session's environment - app = App() - env = app.environ(session) - app.initialize(env) + # 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 From ad92cb8fcd005b981873f210e04700b472e86435 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 6 Jan 2021 13:06:55 +0100 Subject: [PATCH 06/32] formatting changes --- pype/tools/workfiles/app.py | 81 +++++++++++++++-------------------- pype/tools/workfiles/model.py | 13 +++--- 2 files changed, 42 insertions(+), 52 deletions(-) diff --git a/pype/tools/workfiles/app.py b/pype/tools/workfiles/app.py index e402636b53..7ffdf89247 100644 --- a/pype/tools/workfiles/app.py +++ b/pype/tools/workfiles/app.py @@ -429,9 +429,11 @@ class FilesWidget(QtWidgets.QWidget): """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) + changes = pipeline.compute_session_changes( + session, + asset=self._asset, + task=self._task + ) session.update(changes) return session @@ -440,9 +442,11 @@ class FilesWidget(QtWidgets.QWidget): """Enter the asset and task session currently selected""" session = api.Session.copy() - changes = pipeline.compute_session_changes(session, - asset=self._asset, - task=self._task) + 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. @@ -454,13 +458,12 @@ class FilesWidget(QtWidgets.QWidget): 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 @@ -474,17 +477,12 @@ class FilesWidget(QtWidgets.QWidget): # Save current scene, continue to open file host.save_file(current_file) - else: - # Don't save, continue to open file - pass - self._enter_session() host.open_file(filepath) self.window().close() def save_changes_prompt(self): - self._messagebox = QtWidgets.QMessageBox() - messagebox = self._messagebox + self._messagebox = messagebox = QtWidgets.QMessageBox() messagebox.setWindowFlags(QtCore.Qt.FramelessWindowHint) messagebox.setIcon(messagebox.Warning) @@ -502,13 +500,11 @@ class FilesWidget(QtWidgets.QWidget): messagebox.setStyleSheet(style.load_stylesheet()) result = messagebox.exec_() - if result == messagebox.Yes: return True elif result == messagebox.No: return False - else: - return None + return None def get_filename(self): """Show save dialog to define filename for save or duplicate @@ -519,24 +515,22 @@ class FilesWidget(QtWidgets.QWidget): """ session = self._get_session() - window = NameWindow(parent=self, - root=self.root, - session=session) + window = NameWindow( + parent=self, + root=self.root, + 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 - ) + dst = os.path.join(self.root, work_file) shutil.copy(src, dst) self.refresh() @@ -553,7 +547,6 @@ class FilesWidget(QtWidgets.QWidget): return index.data(model.FilePathRole) def on_open_pressed(self): - path = self._get_selected_filepath() if not path: print("No file selected to open..") @@ -562,26 +555,23 @@ class FilesWidget(QtWidgets.QWidget): self.open_file(path) def on_browse_pressed(self): - - filter = " *".join(self.host.file_extensions()) - filter = "Work File (*{0})".format(filter) + ext_filter = "Work File (*{0})".format( + " *".join(self.host.file_extensions()) + ) kwargs = { "caption": "Work Files", - "filter": filter + "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 not work_file: - return - - self.open_file(work_file) + if work_file: + self.open_file(work_file) def on_save_as_pressed(self): - work_file = self.get_filename() if not work_file: return @@ -592,8 +582,9 @@ class FilesWidget(QtWidgets.QWidget): self.initialize_work_directory() if not os.path.exists(self.root): # Failed to initialize Work Directory - log.error("Failed to initialize Work Directory: " - "%s", self.root) + log.error( + "Failed to initialize Work Directory: {}".format(self.root) + ) return file_path = os.path.join(self.root, work_file) @@ -616,9 +607,11 @@ class FilesWidget(QtWidgets.QWidget): # Inputs (from the switched session and running app) session = api.Session.copy() - changes = pipeline.compute_session_changes(session, - asset=self._asset, - task=self._task) + changes = pipeline.compute_session_changes( + session, + asset=self._asset, + task=self._task + ) session.update(changes) # Prepare documents to get workdir data @@ -647,11 +640,9 @@ class FilesWidget(QtWidgets.QWidget): self.model.refresh() if self.auto_select_latest_modified: - tools_lib.schedule(self._select_last_modified_file, - 100) + tools_lib.schedule(self._select_last_modified_file, 100) def on_context_menu(self, point): - view = self.widgets["list"] index = view.indexAt(point) if not index.isValid(): @@ -679,7 +670,6 @@ class FilesWidget(QtWidgets.QWidget): def _select_last_modified_file(self): """Utility function to select the file with latest date modified""" - role = self.model.DateModifiedRole view = self.widgets["list"] model = view.model() @@ -773,7 +763,6 @@ class Window(QtWidgets.QMainWindow): tools_lib.schedule(self._on_asset_changed, 50, channel="mongo") def set_context(self, context): - if "asset" in context: asset = context["asset"] asset_document = io.find_one( diff --git a/pype/tools/workfiles/model.py b/pype/tools/workfiles/model.py index ee8dfbadd9..ded5779049 100644 --- a/pype/tools/workfiles/model.py +++ b/pype/tools/workfiles/model.py @@ -1,10 +1,10 @@ import os import logging -from avalon import style from Qt import QtCore -from avalon.vendor import qtawesome +from avalon import style +from avalon.vendor import qtawesome from avalon.tools.models import TreeModel, Item log = logging.getLogger(__name__) @@ -71,18 +71,19 @@ class FilesModel(TreeModel): extensions = self._file_extensions - for f in os.listdir(root): - path = os.path.join(root, f) + for filename in os.listdir(root): + path = os.path.join(root, filename) if os.path.isdir(path): continue - if extensions and os.path.splitext(f)[1] not in extensions: + ext = os.path.splitext(filename)[1] + if extensions and ext not in extensions: continue modified = os.path.getmtime(path) item = Item({ - "filename": f, + "filename": filename, "date": modified, "filepath": path }) From 78d67027172f839c80a87f385633d602c9d744de Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 6 Jan 2021 13:08:33 +0100 Subject: [PATCH 07/32] removed AVALON_HIERARCHY from context keys --- pype/hooks/global/pre_global_host_data.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/pype/hooks/global/pre_global_host_data.py b/pype/hooks/global/pre_global_host_data.py index 199538e9bb..cb497814f5 100644 --- a/pype/hooks/global/pre_global_host_data.py +++ b/pype/hooks/global/pre_global_host_data.py @@ -147,7 +147,6 @@ class GlobalHostDataHook(PreLaunchHook): ) self.data["workdir_data"] = workdir_data - hierarchy = workdir_data["hierarchy"] anatomy = self.data["anatomy"] try: @@ -169,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( From 01cec68dea422a60c9ec2ede283a01c9af40350c Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 6 Jan 2021 15:33:34 +0100 Subject: [PATCH 08/32] get_workdir can return rootless workdir --- pype/lib/avalon_context.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/pype/lib/avalon_context.py b/pype/lib/avalon_context.py index da80002b07..4a60cbc214 100644 --- a/pype/lib/avalon_context.py +++ b/pype/lib/avalon_context.py @@ -276,7 +276,7 @@ def get_workdir_data(project_doc, asset_doc, task_name, host_name): def get_workdir_with_workdir_data( - workdir_data, anatomy=None, project_name=None + workdir_data, anatomy=None, project_name=None, rootless=False ): """Fill workdir path from entered data and project's anatomy. @@ -290,6 +290,7 @@ def get_workdir_with_workdir_data( `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. + rootless (bool): Return workdir without filled root key when `True`. Returns: str: Workdir path. @@ -306,11 +307,15 @@ def get_workdir_with_workdir_data( if not anatomy: anatomy = Anatomy(project_name) anatomy_filled = anatomy.format(workdir_data) - workdir = os.path.normpath(anatomy_filled["work"]["folder"]) - return workdir + result = anatomy_filled["work"]["folder"] + if rootless: + result = result.rootless + return os.path.normpath(result) -def get_workdir(project_doc, asset_doc, task_name, host_name, anatomy=None): +def get_workdir( + project_doc, asset_doc, task_name, host_name, anatomy=None, rootless=False +): """Fill workdir path from entered data and project's anatomy. Args: @@ -324,6 +329,10 @@ def get_workdir(project_doc, asset_doc, task_name, host_name, anatomy=None): project name from `project_doc`. It is preffered to pass this argument as initialization of a new Anatomy object may be time consuming. + rootless (bool): Return workdir without filled root key when `True`. + + Returns: + str: Workdir path. """ if not anatomy: anatomy = Anatomy(project_doc["name"]) @@ -331,7 +340,7 @@ def get_workdir(project_doc, asset_doc, task_name, host_name, anatomy=None): workdir_data = get_workdir_data( project_doc, asset_doc, task_name, host_name ) - return get_workdir_with_workdir_data(workdir_data, anatomy) + return get_workdir_with_workdir_data(workdir_data, anatomy, rootless) class BuildWorkfile: From 675aa28eacf61bdc8798110b40406981da2e6c9f Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 6 Jan 2021 16:38:58 +0100 Subject: [PATCH 09/32] replaced `rootless` kwarg with `template_key` --- pype/lib/avalon_context.py | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/pype/lib/avalon_context.py b/pype/lib/avalon_context.py index 4a60cbc214..6e3ba1880c 100644 --- a/pype/lib/avalon_context.py +++ b/pype/lib/avalon_context.py @@ -276,7 +276,7 @@ def get_workdir_data(project_doc, asset_doc, task_name, host_name): def get_workdir_with_workdir_data( - workdir_data, anatomy=None, project_name=None, rootless=False + workdir_data, anatomy=None, project_name=None, template_key=None ): """Fill workdir path from entered data and project's anatomy. @@ -290,7 +290,8 @@ def get_workdir_with_workdir_data( `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. - rootless (bool): Return workdir without filled root key when `True`. + template_key (str): Key of work templates in anatomy templates. By + default is seto to `"work"`. Returns: str: Workdir path. @@ -306,15 +307,21 @@ def get_workdir_with_workdir_data( if not anatomy: anatomy = Anatomy(project_name) + + if not template_key: + template_key = "work" + anatomy_filled = anatomy.format(workdir_data) - result = anatomy_filled["work"]["folder"] - if rootless: - result = result.rootless - return os.path.normpath(result) + return anatomy_filled[template_key]["folder"] def get_workdir( - project_doc, asset_doc, task_name, host_name, anatomy=None, rootless=False + project_doc, + asset_doc, + task_name, + host_name, + anatomy=None, + template_key=None ): """Fill workdir path from entered data and project's anatomy. @@ -329,7 +336,8 @@ def get_workdir( project name from `project_doc`. It is preffered to pass this argument as initialization of a new Anatomy object may be time consuming. - rootless (bool): Return workdir without filled root key when `True`. + template_key (str): Key of work templates in anatomy templates. Default + value is defined in `get_workdir_with_workdir_data`. Returns: str: Workdir path. @@ -340,7 +348,7 @@ def get_workdir( workdir_data = get_workdir_data( project_doc, asset_doc, task_name, host_name ) - return get_workdir_with_workdir_data(workdir_data, anatomy, rootless) + return get_workdir_with_workdir_data(workdir_data, anatomy, template_key) class BuildWorkfile: From 01cf7f2cb0983857f180ed9999e58d592da3e18a Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 6 Jan 2021 17:29:08 +0100 Subject: [PATCH 10/32] modified save as dialog to contain note and modification from 2.x develop --- pype/tools/workfiles/app.py | 288 ++++++++++++++++++++++++------------ 1 file changed, 194 insertions(+), 94 deletions(-) diff --git a/pype/tools/workfiles/app.py b/pype/tools/workfiles/app.py index 7ffdf89247..f73baab624 100644 --- a/pype/tools/workfiles/app.py +++ b/pype/tools/workfiles/app.py @@ -36,92 +36,140 @@ class NameWindow(QtWidgets.QDialog): """ - def __init__(self, parent, root, session=None): + 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.result_note = None self.host = api.registered_host() self.root = root self.work_file = None - if session is None: + if not session: # Fallback to active session session = api.Session # Set work file data for template formatting - project = io.find_one({ + asset_name = session["AVALON_ASSET"] + project_doc = io.find_one({ "type": "project" }) + asset_doc = io.find_one({ + "type": "asset", + "name": asset_name + }) self.data = { "project": { - "name": project["name"], - "code": project["data"].get("code") + "name": project_doc["name"], + "code": project_doc["data"].get("code") }, - "asset": session["AVALON_ASSET"], + "asset": asset_name, "task": session["AVALON_TASK"], "version": 1, "user": getpass.getuser(), - "comment": "" + "comment": "", + "ext": None, + "note": "" } - # Define work files template - anatomy = Anatomy(project["name"]) - self.template = anatomy.templates["work"]["file"] + # Store project anatomy + self.anatomy = anatomy + self.template = anatomy.templates[template_key]["file"] + self.template_key = template_key + self.asset_doc = asset_doc - self.widgets = { - "preview": QtWidgets.QLabel("Preview filename"), - "comment": QtWidgets.QLineEdit(), - "version": QtWidgets.QWidget(), - "versionValue": QtWidgets.QSpinBox(), - "versionCheck": QtWidgets.QCheckBox("Next Available Version"), - "inputs": QtWidgets.QWidget(), - "buttons": QtWidgets.QWidget(), - "okButton": QtWidgets.QPushButton("Ok"), - "cancelButton": QtWidgets.QPushButton("Cancel") - } + # Btns widget + btns_widget = QtWidgets.QWidget(self) - # Build version - self.widgets["versionValue"].setMinimum(1) - self.widgets["versionValue"].setMaximum(9999) - self.widgets["versionCheck"].setCheckState(QtCore.Qt.CheckState(2)) - layout = QtWidgets.QHBoxLayout(self.widgets["version"]) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(self.widgets["versionValue"]) - layout.addWidget(self.widgets["versionCheck"]) + btn_ok = QtWidgets.QPushButton("Ok", btns_widget) + btn_cancel = QtWidgets.QPushButton("Cancel", btns_widget) - # Build buttons - layout = QtWidgets.QHBoxLayout(self.widgets["buttons"]) - layout.addWidget(self.widgets["okButton"]) - layout.addWidget(self.widgets["cancelButton"]) + 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) + + # Comment input + comment_input = QtWidgets.QLineEdit(inputs_widget) + comment_input.setPlaceholderText("Will be part of filename.") + + # Extensions combobox + ext_combo = QtWidgets.QComboBox(inputs_widget) + ext_combo.addItems(self.host.file_extensions()) + + # Note input + note_input = QtWidgets.QLineEdit(inputs_widget) + note_input.setPlaceholderText("Artist note to workfile") # Build inputs - layout = QtWidgets.QFormLayout(self.widgets["inputs"]) - layout.addRow("Version:", self.widgets["version"]) - layout.addRow("Comment:", self.widgets["comment"]) - layout.addRow("Preview:", self.widgets["preview"]) + inputs_layout = QtWidgets.QFormLayout(inputs_widget) + inputs_layout.addRow("Version:", version_widget) + inputs_layout.addRow("Comment:", comment_input) + inputs_layout.addRow("Extension:", ext_combo) + inputs_layout.addRow("Preview:", preview_label) + inputs_layout.addRow("Note:", note_input) # Build layout - layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(self.widgets["inputs"]) - layout.addWidget(self.widgets["buttons"]) + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.addWidget(inputs_widget) + main_layout.addWidget(btns_widget) - self.widgets["versionValue"].valueChanged.connect( - self.on_version_spinbox_changed - ) - self.widgets["versionCheck"].stateChanged.connect( + # Singal callback registration + version_input.valueChanged.connect(self.on_version_spinbox_changed) + last_version_check.stateChanged.connect( self.on_version_checkbox_changed ) - self.widgets["comment"].textChanged.connect(self.on_comment_changed) - self.widgets["okButton"].pressed.connect(self.on_ok_pressed) - self.widgets["cancelButton"].pressed.connect(self.on_cancel_pressed) + + comment_input.textChanged.connect(self.on_comment_changed) + ext_combo.currentIndexChanged.connect(self.on_extension_changed) + note_input.textChanged.connect(self.on_note_changed) + + btn_ok.pressed.connect(self.on_ok_pressed) + btn_cancel.pressed.connect(self.on_cancel_pressed) # Allow "Enter" key to accept the save. - self.widgets["okButton"].setDefault(True) + btn_ok.setDefault(True) # Force default focus to comment, some hosts didn't automatically # apply focus to this line edit (e.g. Houdini) - self.widgets["comment"].setFocus() + comment_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.comment_input = comment_input + self.ext_combo = ext_combo + self.note_input = note_input self.refresh() @@ -129,15 +177,26 @@ class NameWindow(QtWidgets.QDialog): self.data["version"] = value self.refresh() - def on_version_checkbox_changed(self, value): + def on_version_checkbox_changed(self, _value): self.refresh() def on_comment_changed(self, text): self.data["comment"] = text self.refresh() + def on_note_changed(self, text): + self.data["note"] = text + + 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.replace("\\", "/") + self.result = self.work_file + self.result_note = self.data["note"] self.close() def on_cancel_pressed(self): @@ -146,42 +205,57 @@ class NameWindow(QtWidgets.QDialog): def get_result(self): return self.result - def get_work_file(self, template=None): + def get_result_note(self): + return self.result_note + + def get_work_file(self): data = copy.deepcopy(self.data) - template = template or self.template - - # 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: - # Fall back to the first extension supported for this host. - extension = self.host.file_extensions()[0] - - data["ext"] = extension - if not data["comment"]: data.pop("comment", None) - return api.format_template_with_optional_keys(data, template) + anatomy_filled = self.anatomy.format(data) + return anatomy_filled[self.template_key]["file"] def refresh(self): # Since the version can be padded with "{version:0>4}" we only search # for "{version". if "{version" not in self.template: - # todo: hide the full row - self.widgets["version"].setVisible(False) + # TODO hide the full row + self.version_widget.setVisible(False) # Build comment if "{comment}" not in self.template: - # todo: hide the full row - self.widgets["comment"].setVisible(False) + # TODO hide the full row + self.comment_input.setVisible(False) - if self.widgets["versionCheck"].isChecked(): - self.widgets["versionValue"].setEnabled(False) + 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) - extensions = self.host.file_extensions() data = copy.deepcopy(self.data) template = str(self.template) @@ -197,32 +271,49 @@ class NameWindow(QtWidgets.QDialog): else: version += 1 - self.data["version"] = version + 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 - # safety check - path = os.path.join(self.root, self.get_work_file()) - assert not os.path.exists(path), \ - "This is a bug, file exists: %s" % path + 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 - else: - self.widgets["versionValue"].setEnabled(True) - self.data["version"] = self.widgets["versionValue"].value() + # 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 = self.get_work_file() + self.work_file = work_file - preview = self.widgets["preview"] - ok = self.widgets["okButton"] - preview.setText( - "{0}".format(self.work_file) - ) - if os.path.exists(os.path.join(self.root, self.work_file)): - preview.setText( + 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(self.work_file) + "".format(work_file) ) - ok.setEnabled(False) else: - ok.setEnabled(True) + self.preview_label.setText( + "{0}".format(work_file) + ) class TasksWidget(QtWidgets.QWidget): @@ -334,6 +425,15 @@ class FilesWidget(QtWidgets.QWidget): # 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" + + # Do not set root with Anatomy's roots because it would break host's + # implementation of `work_root` function self.root = None self.host = api.registered_host() @@ -518,6 +618,8 @@ class FilesWidget(QtWidgets.QWidget): window = NameWindow( parent=self, root=self.root, + anatomy=self.anatomy, + template_key=self.template_key, session=session ) window.exec_() @@ -785,7 +887,6 @@ class Window(QtWidgets.QMainWindow): self.widgets["tasks"].select_task(context["task"]) def refresh(self): - # Refresh asset widget self.widgets["assets"].refresh() @@ -806,7 +907,6 @@ class Window(QtWidgets.QMainWindow): self.widgets["tasks"].set_asset(asset) def _on_task_changed(self): - asset = self.widgets["assets"].get_selected_assets() or None if asset is not None: asset = asset[0] From 59b8fceee9093a1bfdb3de2e2b57bf5b5716429f Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 6 Jan 2021 17:42:01 +0100 Subject: [PATCH 11/32] renammed comment to subversion and do check about version and comment only on initialization --- pype/tools/workfiles/app.py | 37 +++++++++++++++++-------------------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/pype/tools/workfiles/app.py b/pype/tools/workfiles/app.py index f73baab624..ae626aa59c 100644 --- a/pype/tools/workfiles/app.py +++ b/pype/tools/workfiles/app.py @@ -114,9 +114,9 @@ class NameWindow(QtWidgets.QDialog): # Preview widget preview_label = QtWidgets.QLabel("Preview filename", inputs_widget) - # Comment input - comment_input = QtWidgets.QLineEdit(inputs_widget) - comment_input.setPlaceholderText("Will be part of filename.") + # Subversion input + subversion_input = QtWidgets.QLineEdit(inputs_widget) + subversion_input.setPlaceholderText("Will be part of filename.") # Extensions combobox ext_combo = QtWidgets.QComboBox(inputs_widget) @@ -128,11 +128,18 @@ class NameWindow(QtWidgets.QDialog): # Build inputs inputs_layout = QtWidgets.QFormLayout(inputs_widget) - inputs_layout.addRow("Version:", version_widget) - inputs_layout.addRow("Comment:", comment_input) + # 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) + inputs_layout.addRow("Extension:", ext_combo) - inputs_layout.addRow("Preview:", preview_label) + # Add subversion only if template containt `{comment}` + if "{comment}" in self.template: + inputs_layout.addRow("Subversion:", subversion_input) inputs_layout.addRow("Note:", note_input) + inputs_layout.addRow("Preview:", preview_label) # Build layout main_layout = QtWidgets.QVBoxLayout(self) @@ -145,7 +152,7 @@ class NameWindow(QtWidgets.QDialog): self.on_version_checkbox_changed ) - comment_input.textChanged.connect(self.on_comment_changed) + subversion_input.textChanged.connect(self.on_comment_changed) ext_combo.currentIndexChanged.connect(self.on_extension_changed) note_input.textChanged.connect(self.on_note_changed) @@ -157,17 +164,18 @@ class NameWindow(QtWidgets.QDialog): # Force default focus to comment, some hosts didn't automatically # apply focus to this line edit (e.g. Houdini) - comment_input.setFocus() + 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.comment_input = comment_input + self.subversion_input = subversion_input self.ext_combo = ext_combo self.note_input = note_input @@ -217,17 +225,6 @@ class NameWindow(QtWidgets.QDialog): return anatomy_filled[self.template_key]["file"] def refresh(self): - # Since the version can be padded with "{version:0>4}" we only search - # for "{version". - if "{version" not in self.template: - # TODO hide the full row - self.version_widget.setVisible(False) - - # Build comment - if "{comment}" not in self.template: - # TODO hide the full row - self.comment_input.setVisible(False) - extensions = self.host.file_extensions() extension = self.data["ext"] if extension is None: From d1d7f3e48f518026e26ee0a7d3e2f23ee1697b83 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 6 Jan 2021 18:26:35 +0100 Subject: [PATCH 12/32] do not query asset document --- pype/tools/workfiles/app.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/pype/tools/workfiles/app.py b/pype/tools/workfiles/app.py index ae626aa59c..12bfdb1bdc 100644 --- a/pype/tools/workfiles/app.py +++ b/pype/tools/workfiles/app.py @@ -55,10 +55,6 @@ class NameWindow(QtWidgets.QDialog): project_doc = io.find_one({ "type": "project" }) - asset_doc = io.find_one({ - "type": "asset", - "name": asset_name - }) self.data = { "project": { "name": project_doc["name"], @@ -77,7 +73,6 @@ class NameWindow(QtWidgets.QDialog): self.anatomy = anatomy self.template = anatomy.templates[template_key]["file"] self.template_key = template_key - self.asset_doc = asset_doc # Btns widget btns_widget = QtWidgets.QWidget(self) From 3a70e1276daed9f8c2830d12e0e0fa8be266aee8 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 6 Jan 2021 18:28:15 +0100 Subject: [PATCH 13/32] note is not part of workfile save as dialog --- pype/tools/workfiles/app.py | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/pype/tools/workfiles/app.py b/pype/tools/workfiles/app.py index 12bfdb1bdc..1e777c03a5 100644 --- a/pype/tools/workfiles/app.py +++ b/pype/tools/workfiles/app.py @@ -41,7 +41,6 @@ class NameWindow(QtWidgets.QDialog): self.setWindowFlags(self.windowFlags() | QtCore.Qt.FramelessWindowHint) self.result = None - self.result_note = None self.host = api.registered_host() self.root = root self.work_file = None @@ -65,8 +64,7 @@ class NameWindow(QtWidgets.QDialog): "version": 1, "user": getpass.getuser(), "comment": "", - "ext": None, - "note": "" + "ext": None } # Store project anatomy @@ -117,10 +115,6 @@ class NameWindow(QtWidgets.QDialog): ext_combo = QtWidgets.QComboBox(inputs_widget) ext_combo.addItems(self.host.file_extensions()) - # Note input - note_input = QtWidgets.QLineEdit(inputs_widget) - note_input.setPlaceholderText("Artist note to workfile") - # Build inputs inputs_layout = QtWidgets.QFormLayout(inputs_widget) # Add version only if template contain version key @@ -129,11 +123,10 @@ class NameWindow(QtWidgets.QDialog): if "{version" in self.template: inputs_layout.addRow("Version:", version_widget) - inputs_layout.addRow("Extension:", ext_combo) # Add subversion only if template containt `{comment}` if "{comment}" in self.template: inputs_layout.addRow("Subversion:", subversion_input) - inputs_layout.addRow("Note:", note_input) + inputs_layout.addRow("Extension:", ext_combo) inputs_layout.addRow("Preview:", preview_label) # Build layout @@ -149,7 +142,6 @@ class NameWindow(QtWidgets.QDialog): subversion_input.textChanged.connect(self.on_comment_changed) ext_combo.currentIndexChanged.connect(self.on_extension_changed) - note_input.textChanged.connect(self.on_note_changed) btn_ok.pressed.connect(self.on_ok_pressed) btn_cancel.pressed.connect(self.on_cancel_pressed) @@ -172,7 +164,6 @@ class NameWindow(QtWidgets.QDialog): self.preview_label = preview_label self.subversion_input = subversion_input self.ext_combo = ext_combo - self.note_input = note_input self.refresh() @@ -187,9 +178,6 @@ class NameWindow(QtWidgets.QDialog): self.data["comment"] = text self.refresh() - def on_note_changed(self, text): - self.data["note"] = text - def on_extension_changed(self): ext = self.ext_combo.currentText() if ext == self.data["ext"]: @@ -199,7 +187,6 @@ class NameWindow(QtWidgets.QDialog): def on_ok_pressed(self): self.result = self.work_file - self.result_note = self.data["note"] self.close() def on_cancel_pressed(self): @@ -208,9 +195,6 @@ class NameWindow(QtWidgets.QDialog): def get_result(self): return self.result - def get_result_note(self): - return self.result_note - def get_work_file(self): data = copy.deepcopy(self.data) if not data["comment"]: From 709ef29c03d73fc928c309e2eac53505e35b7442 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 6 Jan 2021 19:08:38 +0100 Subject: [PATCH 14/32] dont use dictionaries to store widgets as it's hard for orientation in code --- pype/tools/workfiles/app.py | 215 ++++++++++++++++++------------------ 1 file changed, 108 insertions(+), 107 deletions(-) diff --git a/pype/tools/workfiles/app.py b/pype/tools/workfiles/app.py index 1e777c03a5..303fcd8812 100644 --- a/pype/tools/workfiles/app.py +++ b/pype/tools/workfiles/app.py @@ -421,67 +421,72 @@ class FilesWidget(QtWidgets.QWidget): # (setting parent doesn't work as it hides the message box) self._messagebox = None - widgets = { - "filter": QtWidgets.QLineEdit(), - "list": FilesView(), - "open": QtWidgets.QPushButton("Open"), - "browse": QtWidgets.QPushButton("Browse"), - "save": QtWidgets.QPushButton("Save As") - } + files_view = FilesView(self) - delegates = { - "time": PrettyTimeDelegate() - } - - # Create the files model + # Create the Files model extensions = set(self.host.file_extensions()) - self.model = FilesModel(file_extensions=extensions) - self.proxy = QtCore.QSortFilterProxyModel() - self.proxy.setSourceModel(self.model) - self.proxy.setDynamicSortFilter(True) - self.proxy.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) + 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 - widgets["list"].setModel(self.proxy) - widgets["list"].setSortingEnabled(True) - widgets["list"].setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + files_view.setModel(proxy_model) + files_view.setSortingEnabled(True) + files_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + # Date modified delegate - widgets["list"].setItemDelegateForColumn(1, delegates["time"]) - widgets["list"].setIndentation(3) # smaller indentation + 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. - widgets["list"].setColumnWidth(0, 330) + files_view.setColumnWidth(0, 330) - widgets["filter"].textChanged.connect(self.proxy.setFilterFixedString) - widgets["filter"].setPlaceholderText("Filter files..") + # 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 - buttons = QtWidgets.QWidget() - layout = QtWidgets.QHBoxLayout(buttons) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(widgets["open"]) - layout.addWidget(widgets["browse"]) - layout.addWidget(widgets["save"]) + 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 - layout = QtWidgets.QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(widgets["filter"]) - layout.addWidget(widgets["list"]) - layout.addWidget(buttons) + 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) - widgets["list"].doubleClickedLeft.connect(self.on_open_pressed) - widgets["list"].customContextMenuRequested.connect( - self.on_context_menu - ) - widgets["open"].pressed.connect(self.on_open_pressed) - widgets["browse"].pressed.connect(self.on_browse_pressed) - widgets["save"].pressed.connect(self.on_save_as_pressed) + # Register signal callbacks + files_view.doubleClickedLeft.connect(self.on_open_pressed) + files_view.customContextMenuRequested.connect(self.on_context_menu) - self.widgets = widgets - self.delegates = delegates + 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 widgets + self.files_view = files_view + self.files_model = files_model + + 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 @@ -495,11 +500,11 @@ class FilesWidget(QtWidgets.QWidget): self.root = self.host.work_root(session) exists = os.path.exists(self.root) - self.widgets["browse"].setEnabled(exists) - self.widgets["open"].setEnabled(exists) - self.model.set_root(self.root) + self.btn_browse.setEnabled(exists) + self.btn_open.setEnabled(exists) + self.files_model.set_root(self.root) else: - self.model.set_root(None) + self.files_model.set_root(None) def _get_session(self): """Return a modified session for the current asset and task""" @@ -615,14 +620,12 @@ class FilesWidget(QtWidgets.QWidget): def _get_selected_filepath(self): """Return current filepath selected in view""" - model = self.model - view = self.widgets["list"] - selection = view.selectionModel() + selection = self.files_view.selectionModel() index = selection.currentIndex() if not index.isValid(): return - return index.data(model.FilePathRole) + return index.data(self.files_model.FilePathRole) def on_open_pressed(self): path = self._get_selected_filepath() @@ -715,14 +718,13 @@ class FilesWidget(QtWidgets.QWidget): def refresh(self): """Refresh listed files for current selection in the interface""" - self.model.refresh() + 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): - view = self.widgets["list"] - index = view.indexAt(point) + index = self.files_view.indexAt(point) if not index.isValid(): return @@ -741,16 +743,15 @@ class FilesWidget(QtWidgets.QWidget): menu.addAction(action) # Show the context action menu - global_point = view.mapToGlobal(point) + 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.model.DateModifiedRole - view = self.widgets["list"] - model = view.model() + role = self.files_model.DateModifiedRole + model = self.files_view.model() highest_index = None highest = 0 @@ -765,7 +766,7 @@ class FilesWidget(QtWidgets.QWidget): highest = modified if highest_index: - view.setCurrentIndex(highest_index) + self.files_view.setCurrentIndex(highest_index) class Window(QtWidgets.QMainWindow): @@ -777,49 +778,49 @@ class Window(QtWidgets.QMainWindow): self.setWindowTitle(self.title) self.setWindowFlags(QtCore.Qt.Window | QtCore.Qt.WindowCloseButtonHint) - pages = { - "home": QtWidgets.QWidget() - } + pages_widget = QtWidgets.QStackedWidget(self) - widgets = { - "pages": QtWidgets.QStackedWidget(), - "body": QtWidgets.QWidget(), - "assets": AssetWidget(io), - "tasks": TasksWidget(), - "files": FilesWidget() - } + home_page_widget = QtWidgets.QWidget(pages_widget) + home_body_widget = QtWidgets.QWidget(home_page_widget) - self.setCentralWidget(widgets["pages"]) - widgets["pages"].addWidget(pages["home"]) + assets_widget = AssetWidget(io, parent=home_body_widget) + tasks_widget = TasksWidget(home_body_widget) + files_widget = FilesWidget(home_body_widget) + + self.setCentralWidget(pages_widget) + pages_widget.addWidget(home_page_widget) # Build home - layout = QtWidgets.QVBoxLayout(pages["home"]) - layout.addWidget(widgets["body"]) + layout = QtWidgets.QVBoxLayout(home_page_widget) + layout.addWidget(home_body_widget) # Build home - body - layout = QtWidgets.QVBoxLayout(widgets["body"]) - split = QtWidgets.QSplitter() - split.addWidget(widgets["assets"]) - split.addWidget(widgets["tasks"]) - split.addWidget(widgets["files"]) + body_layout = QtWidgets.QVBoxLayout(home_body_widget) + split = QtWidgets.QSplitter(home_body_widget) + split.addWidget(assets_widget) + split.addWidget(tasks_widget) + split.addWidget(files_widget) split.setStretchFactor(0, 1) split.setStretchFactor(1, 1) split.setStretchFactor(2, 3) - layout.addWidget(split) + body_layout.addWidget(split) # Add top margin for tasks to align it visually with files as # the files widget has a filter field which tasks does not. - widgets["tasks"].setContentsMargins(0, 32, 0, 0) + tasks_widget.setContentsMargins(0, 32, 0, 0) # Connect signals - widgets["assets"].current_changed.connect(self.on_asset_changed) - widgets["tasks"].task_changed.connect(self.on_task_changed) + assets_widget.current_changed.connect(self.on_asset_changed) + tasks_widget.task_changed.connect(self.on_task_changed) + + self.assets_widget = assets_widget + self.tasks_widget = tasks_widget + self.files_widget = files_widget - self.widgets = widgets self.refresh() # Force focus on the open button by default, required for Houdini. - self.widgets["files"].widgets["open"].setFocus() + files_widget.btn_open.setFocus() self.resize(900, 600) @@ -854,46 +855,45 @@ class Window(QtWidgets.QMainWindow): ) # Select the asset - self.widgets["assets"].select_assets([asset], expand=True) + self.assets_widget.select_assets([asset], expand=True) # Force a refresh on Tasks? - self.widgets["tasks"].set_asset(asset_document) + self.tasks_widget.set_asset(asset_document) if "task" in context: - self.widgets["tasks"].select_task(context["task"]) + self.tasks_widget.select_task(context["task"]) def refresh(self): # Refresh asset widget - self.widgets["assets"].refresh() + self.assets_widget.refresh() self._on_task_changed() def _on_asset_changed(self): - asset = self.widgets["assets"].get_selected_assets() or None + asset = self.assets_widget.get_selected_assets() or None if not asset: # Force disable the other widgets if no # active selection - self.widgets["tasks"].setEnabled(False) - self.widgets["files"].setEnabled(False) + self.tasks_widget.setEnabled(False) + self.files_widget.setEnabled(False) else: asset = asset[0] - self.widgets["tasks"].setEnabled(True) + self.tasks_widget.setEnabled(True) - self.widgets["tasks"].set_asset(asset) + self.tasks_widget.set_asset(asset) def _on_task_changed(self): - asset = self.widgets["assets"].get_selected_assets() or None + asset = self.assets_widget.get_selected_assets() or None if asset is not None: asset = asset[0] - task = self.widgets["tasks"].get_current_task() + task = self.tasks_widget.get_current_task() - self.widgets["tasks"].setEnabled(bool(asset)) - self.widgets["files"].setEnabled(all([bool(task), bool(asset)])) + self.tasks_widget.setEnabled(bool(asset)) - files = self.widgets["files"] - files.set_asset_task(asset, task) - files.refresh() + 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): @@ -939,17 +939,18 @@ def show(root=None, debug=False, parent=None, use_context=True, save=True): 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"]} + context = { + "asset": api.Session["AVALON_ASSET"], + "silo": api.Session["AVALON_SILO"], + "task": api.Session["AVALON_TASK"] + } window.set_context(context) - window.widgets["files"].widgets["save"].setEnabled(save) + window.files_widget.btn_save.setEnabled(save) window.show() window.setStyleSheet(style.load_stylesheet()) From 2b5e55b35a1155b1336f143ef2499167a57e661a Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 7 Jan 2021 10:47:50 +0100 Subject: [PATCH 15/32] add empty file if none of files in root match host's criteria --- pype/tools/workfiles/model.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pype/tools/workfiles/model.py b/pype/tools/workfiles/model.py index ded5779049..b0dd6f2e34 100644 --- a/pype/tools/workfiles/model.py +++ b/pype/tools/workfiles/model.py @@ -39,6 +39,7 @@ class FilesModel(TreeModel): "filename": "No files found.", # Not-selectable "enabled": False, + "date": None, "filepath": None }) @@ -90,6 +91,9 @@ class FilesModel(TreeModel): self.add_child(item) + if self.rowCount() == 0: + self._add_empty() + self.endResetModel() def data(self, index, role): From d58853f1cbbd9213631f748aad343c66286b7dd4 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 7 Jan 2021 10:48:06 +0100 Subject: [PATCH 16/32] override rowCount method --- pype/tools/workfiles/model.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pype/tools/workfiles/model.py b/pype/tools/workfiles/model.py index b0dd6f2e34..6516c80c43 100644 --- a/pype/tools/workfiles/model.py +++ b/pype/tools/workfiles/model.py @@ -96,6 +96,13 @@ class FilesModel(TreeModel): self.endResetModel() + 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 @@ -111,12 +118,15 @@ class FilesModel(TreeModel): 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) From 7b40e9e23e9e85b6c1ef869684af49f82367c236 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 7 Jan 2021 10:50:10 +0100 Subject: [PATCH 17/32] added has_filenames method --- pype/tools/workfiles/model.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pype/tools/workfiles/model.py b/pype/tools/workfiles/model.py index 6516c80c43..368988fd4e 100644 --- a/pype/tools/workfiles/model.py +++ b/pype/tools/workfiles/model.py @@ -96,6 +96,12 @@ class FilesModel(TreeModel): 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 From 70f09f0a23eaaf6e85f31f95c08d985275ee5909 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 7 Jan 2021 10:56:59 +0100 Subject: [PATCH 18/32] files widget trigger file_selected signal on file selection --- pype/tools/workfiles/app.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pype/tools/workfiles/app.py b/pype/tools/workfiles/app.py index 303fcd8812..96eadd0e3b 100644 --- a/pype/tools/workfiles/app.py +++ b/pype/tools/workfiles/app.py @@ -395,6 +395,8 @@ class TasksWidget(QtWidgets.QWidget): class FilesWidget(QtWidgets.QWidget): """A widget displaying files that allows to save and open files.""" + file_selected = QtCore.Signal(object, str, str) + def __init__(self, parent=None): super(FilesWidget, self).__init__(parent=parent) @@ -475,6 +477,9 @@ class FilesWidget(QtWidgets.QWidget): # 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) @@ -675,6 +680,10 @@ class FilesWidget(QtWidgets.QWidget): self.set_asset_task(self._asset, self._task) self.refresh() + def on_file_select(self): + filename = self._get_selected_filepath() + self.file_selected.emit(self._asset, self._task, filename) + def initialize_work_directory(self): """Initialize Work Directory. From 87c8f359f37d801e6fce64dec8031ea72d0fd0e5 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 7 Jan 2021 10:57:13 +0100 Subject: [PATCH 19/32] basic side panel widget --- pype/tools/workfiles/app.py | 67 +++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/pype/tools/workfiles/app.py b/pype/tools/workfiles/app.py index 96eadd0e3b..5b416860f3 100644 --- a/pype/tools/workfiles/app.py +++ b/pype/tools/workfiles/app.py @@ -778,6 +778,73 @@ class FilesWidget(QtWidgets.QWidget): 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_input = QtWidgets.QPlainTextEdit(self) + details_input.setReadOnly(True) + + note_input = QtWidgets.QPlainTextEdit(self) + btn_note_save = QtWidgets.QPushButton("Save", self) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(details_input, 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 = "" + + 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.save_clicked.emit() + + def set_context(self, asset_doc, task_name, filepath): + 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) + if not enabled: + self._orig_note = "" + self.details_input.setPlainText("") + self.note_input.setPlainText("") + return + + filename = os.path.basename(filepath) + workfile_doc = io.find_one({ + "type": "workfile", + "parent": asset_doc["_id"], + "task_name": task_name, + "filename": filename + }) + orig_note = "" + if workfile_doc: + orig_note = workfile_doc.get("note") or orig_note + + self._orig_note = orig_note + self.note_input.setPlainText(orig_note) + + filestat = os.stat(filepath) + lines = ( + "Size: {}".format(filestat.st_size), + ) + self.details_input.setPlainText("\n".join(lines)) + + class Window(QtWidgets.QMainWindow): """Work Files Window""" title = "Work Files" From 36b01ae2d0ac09ae7049c3d73bf1f2acf3e73aac Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 7 Jan 2021 10:57:27 +0100 Subject: [PATCH 20/32] added side panel widget to workfiles tool --- pype/tools/workfiles/app.py | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/pype/tools/workfiles/app.py b/pype/tools/workfiles/app.py index 5b416860f3..c574ec8a8f 100644 --- a/pype/tools/workfiles/app.py +++ b/pype/tools/workfiles/app.py @@ -854,7 +854,9 @@ class Window(QtWidgets.QMainWindow): 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) @@ -862,24 +864,26 @@ class Window(QtWidgets.QMainWindow): 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) - self.setCentralWidget(pages_widget) pages_widget.addWidget(home_page_widget) # Build home - layout = QtWidgets.QVBoxLayout(home_page_widget) - layout.addWidget(home_body_widget) + 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 = QtWidgets.QSplitter(home_body_widget) - split.addWidget(assets_widget) - split.addWidget(tasks_widget) - split.addWidget(files_widget) - split.setStretchFactor(0, 1) - split.setStretchFactor(1, 1) - split.setStretchFactor(2, 3) - body_layout.addWidget(split) + 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. @@ -888,17 +892,22 @@ class Window(QtWidgets.QMainWindow): # 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) 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(900, 600) + self.resize(1000, 600) + + def on_file_select(self, asset_doc, task_name, filepath): + self.side_panel.set_context(asset_doc, task_name, filepath) def keyPressEvent(self, event): """Custom keyPressEvent. From 0dc44382b03260040cdc852119d55322b2be0114 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 7 Jan 2021 10:57:47 +0100 Subject: [PATCH 21/32] different way how to define enabled buttons --- pype/tools/workfiles/app.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/pype/tools/workfiles/app.py b/pype/tools/workfiles/app.py index c574ec8a8f..ee2f5686b5 100644 --- a/pype/tools/workfiles/app.py +++ b/pype/tools/workfiles/app.py @@ -503,14 +503,19 @@ class FilesWidget(QtWidgets.QWidget): if self._asset and self._task: session = self._get_session() self.root = self.host.work_root(session) - - exists = os.path.exists(self.root) - self.btn_browse.setEnabled(exists) - self.btn_open.setEnabled(exists) 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""" From 1ddaba1f596897e11d41e96376ae21f34a1b06bd Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 7 Jan 2021 11:55:04 +0100 Subject: [PATCH 22/32] catch workfile creation --- pype/tools/workfiles/app.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/pype/tools/workfiles/app.py b/pype/tools/workfiles/app.py index ee2f5686b5..54c1be07b4 100644 --- a/pype/tools/workfiles/app.py +++ b/pype/tools/workfiles/app.py @@ -396,6 +396,7 @@ class TasksWidget(QtWidgets.QWidget): class FilesWidget(QtWidgets.QWidget): """A widget displaying files that allows to save and open files.""" file_selected = QtCore.Signal(object, str, str) + workfile_created = QtCore.Signal(str) def __init__(self, parent=None): super(FilesWidget, self).__init__(parent=parent) @@ -410,8 +411,7 @@ class FilesWidget(QtWidgets.QWidget): # TODO change template key based on task self.template_key = "work" - # Do not set root with Anatomy's roots because it would break host's - # implementation of `work_root` function + # This is not root but workfile directory self.root = None self.host = api.registered_host() @@ -626,6 +626,8 @@ class FilesWidget(QtWidgets.QWidget): dst = os.path.join(self.root, work_file) shutil.copy(src, dst) + self.workfile_created.emit(dst) + self.refresh() def _get_selected_filepath(self): @@ -682,7 +684,11 @@ class FilesWidget(QtWidgets.QWidget): 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): @@ -898,6 +904,7 @@ class Window(QtWidgets.QMainWindow): 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) self.assets_widget = assets_widget self.tasks_widget = tasks_widget @@ -911,9 +918,6 @@ class Window(QtWidgets.QMainWindow): self.resize(1000, 600) - def on_file_select(self, asset_doc, task_name, filepath): - self.side_panel.set_context(asset_doc, task_name, filepath) - def keyPressEvent(self, event): """Custom keyPressEvent. @@ -931,6 +935,12 @@ class Window(QtWidgets.QMainWindow): def on_asset_changed(self): tools_lib.schedule(self._on_asset_changed, 50, channel="mongo") + def on_file_select(self, asset_doc, task_name, filepath): + self.side_panel.set_context(asset_doc, task_name, filepath) + + def on_workfile_create(self, filepath): + workdir, filename = os.path.split(filepath) + def set_context(self, context): if "asset" in context: asset = context["asset"] From c3f5ff4e1df13c1f5c723201a72a5b36429cddb5 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 7 Jan 2021 13:34:34 +0100 Subject: [PATCH 23/32] added workfile doc api to lib --- pype/lib/__init__.py | 12 ++++ pype/lib/avalon_context.py | 125 ++++++++++++++++++++++++++++++++++++- 2 files changed, 135 insertions(+), 2 deletions(-) diff --git a/pype/lib/__init__.py b/pype/lib/__init__.py index 0d5dfa4373..cda9c9e10f 100644 --- a/pype/lib/__init__.py +++ b/pype/lib/__init__.py @@ -39,9 +39,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 ) @@ -99,9 +105,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", "PypeHook", diff --git a/pype/lib/avalon_context.py b/pype/lib/avalon_context.py index 6e3ba1880c..46a9e9bf2c 100644 --- a/pype/lib/avalon_context.py +++ b/pype/lib/avalon_context.py @@ -1,10 +1,13 @@ import os import json import re +import copy import logging import collections import functools +from bson.objectid import ObjectId + from pype.settings import get_project_settings from .anatomy import Anatomy @@ -294,7 +297,7 @@ def get_workdir_with_workdir_data( default is seto to `"work"`. Returns: - str: Workdir path. + TemplateResult: Workdir path. Raises: ValueError: When both `anatomy` and `project_name` are set to None. @@ -312,6 +315,7 @@ def get_workdir_with_workdir_data( template_key = "work" anatomy_filled = anatomy.format(workdir_data) + # Output is TemplateResult object which contain usefull data return anatomy_filled[template_key]["folder"] @@ -340,7 +344,7 @@ def get_workdir( value is defined in `get_workdir_with_workdir_data`. Returns: - str: Workdir path. + TemplateResult: Workdir path. """ if not anatomy: anatomy = Anatomy(project_doc["name"]) @@ -348,9 +352,126 @@ def get_workdir( 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["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. From 2a12e7d007a900964e0099a5f43ee8f533107533 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 7 Jan 2021 13:34:57 +0100 Subject: [PATCH 24/32] workfiles tool can store and load workfiles docs --- pype/tools/workfiles/app.py | 98 +++++++++++++++++++++++++++++++------ 1 file changed, 82 insertions(+), 16 deletions(-) diff --git a/pype/tools/workfiles/app.py b/pype/tools/workfiles/app.py index 54c1be07b4..fc5109fabf 100644 --- a/pype/tools/workfiles/app.py +++ b/pype/tools/workfiles/app.py @@ -19,7 +19,10 @@ from .view import FilesView from pype.lib import ( Anatomy, - get_workdir + get_workdir, + get_workfile_doc, + create_workfile_doc, + save_workfile_data_to_doc ) log = logging.getLogger(__name__) @@ -395,7 +398,7 @@ class TasksWidget(QtWidgets.QWidget): class FilesWidget(QtWidgets.QWidget): """A widget displaying files that allows to save and open files.""" - file_selected = QtCore.Signal(object, str, str) + file_selected = QtCore.Signal(str) workfile_created = QtCore.Signal(str) def __init__(self, parent=None): @@ -692,8 +695,7 @@ class FilesWidget(QtWidgets.QWidget): self.refresh() def on_file_select(self): - filename = self._get_selected_filepath() - self.file_selected.emit(self._asset, self._task, filename) + self.file_selected.emit(self._get_selected_filepath()) def initialize_work_directory(self): """Initialize Work Directory. @@ -795,15 +797,19 @@ class SidePanelWidget(QtWidgets.QWidget): 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", 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) @@ -815,46 +821,55 @@ class SidePanelWidget(QtWidgets.QWidget): 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): + 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 - filename = os.path.basename(filepath) - workfile_doc = io.find_one({ - "type": "workfile", - "parent": asset_doc["_id"], - "task_name": task_name, - "filename": filename - }) orig_note = "" if workfile_doc: - orig_note = workfile_doc.get("note") or orig_note + orig_note = workfile_doc["data"].get("note") or orig_note self._orig_note = orig_note self.note_input.setPlainText(orig_note) + # filename = os.path.basename(filepath) filestat = os.stat(filepath) lines = ( "Size: {}".format(filestat.st_size), ) self.details_input.setPlainText("\n".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""" @@ -905,6 +920,7 @@ class Window(QtWidgets.QMainWindow): 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.assets_widget = assets_widget self.tasks_widget = tasks_widget @@ -935,11 +951,61 @@ class Window(QtWidgets.QMainWindow): def on_asset_changed(self): tools_lib.schedule(self._on_asset_changed, 50, channel="mongo") - def on_file_select(self, asset_doc, task_name, filepath): - self.side_panel.set_context(asset_doc, task_name, filepath) + 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): - workdir, filename = os.path.split(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: From 441e40c6f5fe2f67772931b93df53791d83add25 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 7 Jan 2021 13:56:09 +0100 Subject: [PATCH 25/32] better details --- pype/tools/workfiles/app.py | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/pype/tools/workfiles/app.py b/pype/tools/workfiles/app.py index fc5109fabf..7cbafc8fb2 100644 --- a/pype/tools/workfiles/app.py +++ b/pype/tools/workfiles/app.py @@ -4,6 +4,7 @@ import copy import getpass import shutil import logging +import datetime import Qt from Qt import QtWidgets, QtCore @@ -856,13 +857,36 @@ class SidePanelWidget(QtWidgets.QWidget): self._orig_note = orig_note self.note_input.setPlainText(orig_note) + # Set as empty string + self.details_input.setPlainText("") - # filename = os.path.basename(filepath) 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: {}".format(filestat.st_size), + "Size:", + "{:.2f} {}".format(size, ending), + "Created:", + creation_time.strftime(datetime_format), + "Modified:", + modification_time.strftime(datetime_format) ) - self.details_input.setPlainText("\n".join(lines)) + self.details_input.appendHtml("
".join(lines)) def get_workfile_data(self): data = { From 2397a02804c0f4926f6026d88bea4a8ff1c78df6 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 7 Jan 2021 14:02:04 +0100 Subject: [PATCH 26/32] fix dotted extension --- pype/tools/workfiles/app.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pype/tools/workfiles/app.py b/pype/tools/workfiles/app.py index 7cbafc8fb2..3cadae48b4 100644 --- a/pype/tools/workfiles/app.py +++ b/pype/tools/workfiles/app.py @@ -204,6 +204,8 @@ class NameWindow(QtWidgets.QDialog): 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"] @@ -242,6 +244,8 @@ class NameWindow(QtWidgets.QDialog): 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] From 3076d73b7d801fc3bb40575ba1f6d1bc36ffe60a Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 7 Jan 2021 15:41:25 +0100 Subject: [PATCH 27/32] replace workfiles tool with pype's in maya --- pype/hosts/maya/menu.py | 47 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) 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 From 856d6c31ad96e8b96cc414d8298def93b1d673d4 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 7 Jan 2021 16:22:58 +0100 Subject: [PATCH 28/32] store more attributes --- pype/tools/workfiles/__init__.py | 6 ++++-- pype/tools/workfiles/app.py | 12 +++++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/pype/tools/workfiles/__init__.py b/pype/tools/workfiles/__init__.py index 4f49011584..cde7293931 100644 --- a/pype/tools/workfiles/__init__.py +++ b/pype/tools/workfiles/__init__.py @@ -1,7 +1,9 @@ from .app import ( - show + show, + Window ) __all__ = [ - "show" + "show", + "Window" ] diff --git a/pype/tools/workfiles/app.py b/pype/tools/workfiles/app.py index 3cadae48b4..e0928e5fcd 100644 --- a/pype/tools/workfiles/app.py +++ b/pype/tools/workfiles/app.py @@ -493,10 +493,15 @@ class FilesWidget(QtWidgets.QWidget): btn_browse.pressed.connect(self.on_browse_pressed) btn_save.pressed.connect(self.on_save_as_pressed) - # Store widgets + # 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 @@ -950,6 +955,11 @@ class Window(QtWidgets.QMainWindow): 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 From 0cf9cfc8f19c0ea73a459bf03a9567a6f81afab6 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 7 Jan 2021 16:23:48 +0100 Subject: [PATCH 29/32] replace workfiles tool with pype's version in nuke --- pype/hosts/nuke/menu.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/pype/hosts/nuke/menu.py b/pype/hosts/nuke/menu.py index b1ef7f47c4..1b7a0f2de3 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__, "nuke") @@ -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" From 2d6033aaff302fa487fe7166bb93c49a353a86c9 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 7 Jan 2021 16:30:47 +0100 Subject: [PATCH 30/32] added workfile schema --- pype/lib/avalon_context.py | 1 + schema/workfile-1.0.json | 52 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 schema/workfile-1.0.json diff --git a/pype/lib/avalon_context.py b/pype/lib/avalon_context.py index 46a9e9bf2c..7813794752 100644 --- a/pype/lib/avalon_context.py +++ b/pype/lib/avalon_context.py @@ -434,6 +434,7 @@ def create_workfile_doc(asset_doc, task_name, filename, workdir, dbcon=None): template_workdir.rootless.replace("\\", "/") ) + doc_data["schema"] = "pype:workfile-1.0" doc_data["files"] = ["/".join([rootles_workdir, filename])] doc_data["data"] = {} 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"} + } + } +} From 059e5b5ce3b97483da243c42a659b01cd9907362 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 7 Jan 2021 16:33:02 +0100 Subject: [PATCH 31/32] removed unused import --- pype/lib/avalon_context.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/pype/lib/avalon_context.py b/pype/lib/avalon_context.py index 7813794752..fd4155703e 100644 --- a/pype/lib/avalon_context.py +++ b/pype/lib/avalon_context.py @@ -6,8 +6,6 @@ import logging import collections import functools -from bson.objectid import ObjectId - from pype.settings import get_project_settings from .anatomy import Anatomy From b15880e0d841addc8d53c6546fd70a6deaa5146e Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 7 Jan 2021 18:13:32 +0100 Subject: [PATCH 32/32] changed label of save button for note to "Save note" --- pype/tools/workfiles/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pype/tools/workfiles/app.py b/pype/tools/workfiles/app.py index e0928e5fcd..e6b211152a 100644 --- a/pype/tools/workfiles/app.py +++ b/pype/tools/workfiles/app.py @@ -813,7 +813,7 @@ class SidePanelWidget(QtWidgets.QWidget): note_label = QtWidgets.QLabel("Artist note", self) note_input = QtWidgets.QPlainTextEdit(self) - btn_note_save = QtWidgets.QPushButton("Save", self) + btn_note_save = QtWidgets.QPushButton("Save note", self) main_layout = QtWidgets.QVBoxLayout(self) main_layout.setContentsMargins(0, 0, 0, 0)