mirror of
https://github.com/ynput/ayon-core.git
synced 2025-12-24 21:04:40 +01:00
copied workfiles tool to pype tools
This commit is contained in:
parent
e5d39665d8
commit
6e95f79dcf
5 changed files with 1193 additions and 0 deletions
143
pype/tools/workfiles/README.md
Normal file
143
pype/tools/workfiles/README.md
Normal file
|
|
@ -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
|
||||
```
|
||||
7
pype/tools/workfiles/__init__.py
Normal file
7
pype/tools/workfiles/__init__.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
from .app import (
|
||||
show
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"show"
|
||||
]
|
||||
893
pype/tools/workfiles/app.py
Normal file
893
pype/tools/workfiles/app.py
Normal file
|
|
@ -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(
|
||||
"<font color='green'>{0}</font>".format(self.work_file)
|
||||
)
|
||||
if os.path.exists(os.path.join(self.root, self.work_file)):
|
||||
preview.setText(
|
||||
"<font color='red'>Cannot create \"{0}\" because file exists!"
|
||||
"</font>".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()
|
||||
134
pype/tools/workfiles/model.py
Normal file
134
pype/tools/workfiles/model.py
Normal file
|
|
@ -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)
|
||||
16
pype/tools/workfiles/view.py
Normal file
16
pype/tools/workfiles/view.py
Normal file
|
|
@ -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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue