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)