diff --git a/avalon/fusion/workio.py b/avalon/fusion/workio.py
index 36159b4a4..e7fa24919 100644
--- a/avalon/fusion/workio.py
+++ b/avalon/fusion/workio.py
@@ -39,11 +39,10 @@ def current_file():
return current_filepath
-def work_root():
- from avalon import Session
+def work_root(session):
- work_dir = Session["AVALON_WORKDIR"]
- scene_dir = Session.get("AVALON_SCENEDIR")
+ work_dir = session["AVALON_WORKDIR"]
+ scene_dir = session.get("AVALON_SCENEDIR")
if scene_dir:
return os.path.join(work_dir, scene_dir)
else:
diff --git a/avalon/houdini/workio.py b/avalon/houdini/workio.py
index 99518215b..2359392a5 100644
--- a/avalon/houdini/workio.py
+++ b/avalon/houdini/workio.py
@@ -48,11 +48,10 @@ def current_file():
return current_filepath
-def work_root():
- from avalon import Session
+def work_root(session):
- work_dir = Session["AVALON_WORKDIR"]
- scene_dir = Session.get("AVALON_SCENEDIR")
+ work_dir = session["AVALON_WORKDIR"]
+ scene_dir = session.get("AVALON_SCENEDIR")
if scene_dir:
return os.path.join(work_dir, scene_dir)
else:
diff --git a/avalon/maya/pipeline.py b/avalon/maya/pipeline.py
index 55c4bb490..7180af3f6 100644
--- a/avalon/maya/pipeline.py
+++ b/avalon/maya/pipeline.py
@@ -128,8 +128,7 @@ def _install_menu():
creator,
loader,
publish,
- sceneinventory,
- contextmanager
+ sceneinventory
)
from . import interactive
@@ -143,19 +142,17 @@ def deferred():
parent="MayaWindow")
# Create context menu
- context_label = "{}, {}".format(api.Session["AVALON_ASSET"],
- api.Session["AVALON_TASK"])
- context_menu = cmds.menuItem("currentContext",
- label=context_label,
- parent=self._menu,
- subMenu=True)
-
- cmds.menuItem("setCurrentContext",
- label="Edit Context..",
- parent=context_menu,
- command=lambda *args: contextmanager.show(
- parent=self._parent
- ))
+ context_label = "{}, {}".format(
+ api.Session["AVALON_ASSET"],
+ api.Session["AVALON_TASK"]
+ )
+
+ cmds.menuItem(
+ "currentContext",
+ label=context_label,
+ parent=self._menu,
+ enable=False
+ )
cmds.setParent("..", menu=True)
@@ -272,13 +269,13 @@ def reload_pipeline(*args):
def _uninstall_menu():
-
+
# In Maya 2020+ don't use the QApplication.instance()
# during startup (userSetup.py) as it will return a
# QtCore.QCoreApplication instance which does not have
# the allWidgets method. As such, we call the staticmethod.
all_widgets = QtWidgets.QApplication.allWidgets()
-
+
widgets = dict((w.objectName(), w) for w in all_widgets)
menu = widgets.get(self._menu)
diff --git a/avalon/maya/workio.py b/avalon/maya/workio.py
index c515bfdc0..9c036a35f 100644
--- a/avalon/maya/workio.py
+++ b/avalon/maya/workio.py
@@ -30,10 +30,33 @@ def current_file():
return current_filepath
-def work_root():
-
- # Base the root on the current Maya workspace.
- return os.path.join(
- cmds.workspace(query=True, rootDirectory=True),
- cmds.workspace(fileRuleEntry="scene")
- )
+def work_root(session):
+ work_dir = session["AVALON_WORKDIR"]
+ scene_dir = None
+
+ # Query scene file rule from workspace.mel if it exists in WORKDIR
+ # We are parsing the workspace.mel manually as opposed to temporarily
+ # setting the Workspace in Maya in a context manager since Maya had a
+ # tendency to crash on frequently changing the workspace when this
+ # function was called many times as one scrolled through Work Files assets.
+ workspace_mel = os.path.join(work_dir, "workspace.mel")
+ if os.path.exists(workspace_mel):
+ scene_rule = 'workspace -fr "scene" '
+ # We need to use builtins as `open` is overridden by the workio API
+ open_file = __builtins__["open"]
+ with open_file(workspace_mel, "r") as f:
+ for line in f:
+ if line.strip().startswith(scene_rule):
+ # remainder == "rule";
+ remainder = line[len(scene_rule):]
+ # scene_dir == rule
+ scene_dir = remainder.split('"')[1]
+ else:
+ # We can't query a workspace that does not exist
+ # so we return similar to what we do in other hosts.
+ scene_dir = session.get("AVALON_SCENEDIR")
+
+ if scene_dir:
+ return os.path.join(work_dir, scene_dir)
+ else:
+ return work_dir
diff --git a/avalon/nuke/pipeline.py b/avalon/nuke/pipeline.py
index 1c1bd7cab..56acca294 100644
--- a/avalon/nuke/pipeline.py
+++ b/avalon/nuke/pipeline.py
@@ -267,8 +267,7 @@ def _install_menu():
publish,
workfiles,
loader,
- sceneinventory,
- contextmanager
+ sceneinventory
)
# Create menu
@@ -278,11 +277,9 @@ def _install_menu():
label = "{0}, {1}".format(
api.Session["AVALON_ASSET"], api.Session["AVALON_TASK"]
)
- context_menu = menu.addMenu(label)
- context_menu.addCommand("Set Context",
- lambda: contextmanager.show(
- parent=get_main_window())
- )
+ context_action = menu.addCommand(label)
+ context_action.setEnabled(False)
+
menu.addSeparator()
menu.addCommand("Create...",
lambda: creator.show(parent=get_main_window()))
diff --git a/avalon/nuke/workio.py b/avalon/nuke/workio.py
index a0dbe091a..65b86bf01 100644
--- a/avalon/nuke/workio.py
+++ b/avalon/nuke/workio.py
@@ -42,6 +42,13 @@ def current_file():
return os.path.normpath(current_file).replace("\\", "/")
-def work_root():
- from avalon import Session
- return os.path.normpath(Session["AVALON_WORKDIR"]).replace("\\", "/")
+def work_root(session):
+
+ work_dir = session["AVALON_WORKDIR"]
+ scene_dir = session.get("AVALON_SCENEDIR")
+ if scene_dir:
+ path = os.path.join(work_dir, scene_dir)
+ else:
+ path = work_dir
+
+ return os.path.normpath(path).replace("\\", "/")
diff --git a/avalon/pipeline.py b/avalon/pipeline.py
index a310908ec..132df6098 100644
--- a/avalon/pipeline.py
+++ b/avalon/pipeline.py
@@ -926,61 +926,111 @@ def get_representation_context(representation):
return context
-def update_current_task(task=None, asset=None, app=None):
- """Update active Session to a new task work area.
+def compute_session_changes(session, task=None, asset=None, app=None):
+ """Compute the changes for a Session object on asset, task or app switch
- This updates the live Session to a different `asset`, `task` or `app`.
+ This does *NOT* update the Session object, but returns the changes
+ required for a valid update of the Session.
Args:
- task (str): The task to set.
- asset (str): The asset to set.
- app (str): The app to set.
+ session (dict): The initial session to compute changes to.
+ This is required for computing the full Work Directory, as that
+ also depends on the values that haven't changed.
+ task (str, Optional): Name of task to switch to.
+ asset (str or dict, Optional): Name of asset to switch to.
+ You can also directly provide the Asset dictionary as returned
+ from the database to avoid an additional query. (optimization)
+ app (str, Optional): Name of app to switch to.
Returns:
- dict: The changed key, values in the current Session.
+ dict: The required changes in the Session dictionary.
"""
+ changes = dict()
+
+ # If no changes, return directly
+ if not any([task, asset, app]):
+ return changes
+
+ # Get asset document and asset
+ asset_document = None
+ if asset:
+ if isinstance(asset, dict):
+ # Assume asset database document
+ asset_document = asset
+ asset = asset["name"]
+ else:
+ # Assume asset name
+ asset_document = io.find_one({"name": asset,
+ "type": "asset"})
+ assert asset_document, "Asset must exist"
+
+ # Detect any changes compared session
mapping = {
"AVALON_ASSET": asset,
"AVALON_TASK": task,
"AVALON_APP": app,
}
- changed = {key: value for key, value in mapping.items() if value}
- if not changed:
- return
+ changes = {key: value for key, value in mapping.items()
+ if value and value != session.get(key)}
+ if not changes:
+ return changes
+
+ # Update silo and hierarchy when asset changed
+ if "AVALON_ASSET" in changes:
- # Update silo when asset changed
- if "AVALON_ASSET" in changed:
- asset_document = io.find_one({"name": changed["AVALON_ASSET"],
- "type": "asset"})
- assert asset_document, "Asset must exist"
- changed["AVALON_SILO"] = asset_document["silo"]
+ # Update silo
+ changes["AVALON_SILO"] = asset_document["silo"]
+
+ # Update hierarchy
+ parents = asset_document['data'].get('parents', [])
+ hierarchy = ""
+ if len(parents) > 0:
+ hierarchy = os.path.sep.join(parents)
+ changes['AVALON_HIERARCHY'] = hierarchy
# Compute work directory (with the temporary changed session so far)
project = io.find_one({"type": "project"},
projection={"config.template.work": True})
template = project["config"]["template"]["work"]
- _session = Session.copy()
- _session.update(changed)
- changed["AVALON_WORKDIR"] = _format_work_template(template, _session)
+ _session = session.copy()
+ _session.update(changes)
+ changes["AVALON_WORKDIR"] = _format_work_template(template, _session)
+
+ return changes
+
+
+def update_current_task(task=None, asset=None, app=None):
+ """Update active Session to a new task work area.
+
+ This updates the live Session to a different `asset`, `task` or `app`.
+
+ Args:
+ task (str): The task to set.
+ asset (str): The asset to set.
+ app (str): The app to set.
+
+ Returns:
+ dict: The changed key, values in the current Session.
+
+ """
- parents = asset_document['data'].get('parents', [])
- hierarchy = ""
- if len(parents) > 0:
- hierarchy = os.path.sep.join(parents)
- changed['AVALON_HIERARCHY'] = hierarchy
+ changes = compute_session_changes(Session,
+ task=task,
+ asset=asset,
+ app=app)
# Update the full session in one go to avoid half updates
- Session.update(changed)
+ Session.update(changes)
# Update the environment
- os.environ.update(changed)
+ os.environ.update(changes)
# Emit session change
- emit("taskChanged", changed.copy())
+ emit("taskChanged", changes.copy())
- return changed
+ return changes
def _format_work_template(template, session=None):
diff --git a/avalon/tools/contextmanager/__init__.py b/avalon/tools/contextmanager/__init__.py
deleted file mode 100644
index 6d11e9eee..000000000
--- a/avalon/tools/contextmanager/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from .app import show
-
-__all__ = ["show"]
diff --git a/avalon/tools/contextmanager/app.py b/avalon/tools/contextmanager/app.py
deleted file mode 100644
index 80edf839f..000000000
--- a/avalon/tools/contextmanager/app.py
+++ /dev/null
@@ -1,229 +0,0 @@
-import sys
-import logging
-
-from ... import api
-
-from ...vendor.Qt import QtWidgets, QtCore
-from ..widgets import AssetWidget
-from ..models import TasksModel
-
-module = sys.modules[__name__]
-module.window = None
-
-
-log = logging.getLogger(__name__)
-
-
-class App(QtWidgets.QDialog):
- """Context manager window"""
-
- def __init__(self, parent=None):
- QtWidgets.QDialog.__init__(self, parent)
-
- self.resize(640, 360)
- project = api.Session["AVALON_PROJECT"]
- self.setWindowTitle("Context Manager 1.0 - {}".format(project))
- self.setObjectName("contextManager")
-
- splitter = QtWidgets.QSplitter(self)
- main_layout = QtWidgets.QVBoxLayout()
- column_layout = QtWidgets.QHBoxLayout()
-
- accept_btn = QtWidgets.QPushButton("Accept")
-
- # Asset picker
- assets = AssetWidget(silo_creatable=False)
-
- # Task picker
- tasks_widgets = QtWidgets.QWidget()
- tasks_widgets.setContentsMargins(0, 0, 0, 0)
- tasks_layout = QtWidgets.QVBoxLayout(tasks_widgets)
- task_view = QtWidgets.QTreeView()
- task_view.setIndentation(0)
- task_model = TasksModel()
- task_view.setModel(task_model)
- task_view_selection = task_view.selectionModel()
- tasks_layout.addWidget(task_view)
- tasks_layout.addWidget(accept_btn)
- task_view.setColumnHidden(1, True)
-
- # region results
- result_widget = QtWidgets.QGroupBox("Current Context")
- result_layout = QtWidgets.QVBoxLayout()
- result_widget.setLayout(result_layout)
-
- project_label = QtWidgets.QLabel("Project: {}".format(project))
- asset_label = QtWidgets.QLabel()
- task_label = QtWidgets.QLabel()
-
- result_layout.addWidget(project_label)
- result_layout.addWidget(asset_label)
- result_layout.addWidget(task_label)
- result_layout.addStretch()
- # endregion results
-
- context_widget = QtWidgets.QWidget()
- column_layout.addWidget(assets)
- column_layout.addWidget(tasks_widgets)
- context_widget.setLayout(column_layout)
-
- splitter.addWidget(context_widget)
- splitter.addWidget(result_widget)
- splitter.setSizes([1, 0])
-
- main_layout.addWidget(splitter)
-
- # Enable for other functions
- self._last_selected_task = None
- self._task_view = task_view
- self._task_model = task_model
- self._assets = assets
- self._accept_button = accept_btn
-
- self._context_asset = asset_label
- self._context_task = task_label
-
- assets.selection_changed.connect(self.on_asset_changed)
- accept_btn.clicked.connect(self.on_accept_clicked)
- task_view_selection.selectionChanged.connect(self.on_task_changed)
- assets.assets_refreshed.connect(self.on_task_changed)
- assets.refresh()
-
- self.select_asset(api.Session["AVALON_ASSET"])
- self.select_task(api.Session["AVALON_TASK"])
-
- self.setLayout(main_layout)
-
- # Enforce current context to be up-to-date
- self.refresh_context_view()
-
- def refresh_context_view(self):
- """Refresh the context panel"""
-
- asset = api.Session.get("AVALON_ASSET", "")
- task = api.Session.get("AVALON_TASK", "")
-
- self._context_asset.setText("Asset: {}".format(asset))
- self._context_task.setText("Task: {}".format(task))
-
- def _get_selected_task_name(self):
-
- # Make sure we actually get the selected entry as opposed to the
- # active index. This way we know the task is actually selected and the
- # view isn't just active on something that is unselectable like
- # "No Task"
- selected = self._task_view.selectionModel().selectedRows()
- if not selected:
- return
-
- task_index = selected[0]
- return task_index.data(QtCore.Qt.DisplayRole)
-
- def _get_selected_asset_name(self):
- asset_index = self._assets.get_active_index()
- asset_data = asset_index.data(self._assets.model.ItemRole)
- if not asset_data or not isinstance(asset_data, dict):
- return
-
- return asset_data["name"]
-
- def on_asset_changed(self):
- """Callback on asset selection changed
-
- This updates the task view.
-
- """
- current_task_data = self._get_selected_task_name()
- if current_task_data:
- self._last_selected_task = current_task_data
-
- selected = self._assets.get_selected_assets()
- self._task_model.set_assets(selected)
-
- # Find task with same name
- if self._last_selected_task:
- self.select_task(self._last_selected_task)
-
- if not self._get_selected_task_name():
- # If no task got selected after the task model reset
- # then a "selection change" signal is not emitted.
- # As such we need to explicitly force the callback.
- self.on_task_changed()
-
- def on_task_changed(self):
- """Callback on task change."""
-
- # Toggle the "Accept" button enabled state
- asset = self._get_selected_asset_name()
- task = self._get_selected_task_name()
- if not asset or not task:
- self._accept_button.setEnabled(False)
- else:
- self._accept_button.setEnabled(True)
-
- def on_accept_clicked(self):
- """Apply the currently selected task to update current task"""
-
- asset_name = self._get_selected_asset_name()
- if not asset_name:
- log.warning("No asset selected.")
- return
-
- task_name = self._get_selected_task_name()
- if not task_name:
- log.warning("No task selected.")
- return
-
- api.update_current_task(task=task_name, asset=asset_name)
- self.refresh_context_view()
-
- def select_task(self, taskname):
- """Select task by name
- Args:
- taskname(str): name of the task to select
-
- Returns:
- None
- """
-
- parent = QtCore.QModelIndex()
- model = self._task_view.model()
- selectionmodel = self._task_view.selectionModel()
-
- for row in range(model.rowCount(parent)):
- idx = model.index(row, 0, parent)
- task = idx.data(QtCore.Qt.DisplayRole)
- if task == taskname:
- selectionmodel.select(idx,
- QtCore.QItemSelectionModel.Select)
- self._task_view.setCurrentIndex(idx)
- self._last_selected_task = taskname
- return
-
- def select_asset(self, assetname):
- """Select task by name
- Args:
- assetname(str): name of the task to select
-
- Returns:
- None
- """
- self._assets.select_assets([assetname], expand=True)
-
-
-def show(parent=None):
-
- from avalon import style
- from ...tools import lib
- try:
- module.window.close()
- del module.window
- except (RuntimeError, AttributeError):
- pass
-
- with lib.application():
- window = App(parent)
- window.show()
- window.setStyleSheet(style.load_stylesheet())
-
- module.window = window
diff --git a/avalon/tools/delegates.py b/avalon/tools/delegates.py
index b7aa053a0..aac90f2a0 100644
--- a/avalon/tools/delegates.py
+++ b/avalon/tools/delegates.py
@@ -1,3 +1,6 @@
+import time
+from datetime import datetime
+import logging
import numbers
from ..vendor.Qt import QtWidgets, QtCore
@@ -5,6 +8,8 @@
from .models import TreeModel
+log = logging.getLogger(__name__)
+
class VersionDelegate(QtWidgets.QStyledItemDelegate):
"""A delegate that display version integer formatted as version string."""
@@ -71,3 +76,108 @@ def setModelData(self, editor, model, index):
"""Apply the integer version back in the model"""
version = editor.itemData(editor.currentIndex())
model.setData(index, version["name"])
+
+
+def pretty_date(t, now=None, strftime="%b %d %Y %H:%M"):
+ """Parse datetime to readable timestamp
+
+ Within first ten seconds:
+ - "just now",
+ Within first minute ago:
+ - "%S seconds ago"
+ Within one hour ago:
+ - "%M minutes ago".
+ Within one day ago:
+ - "%H:%M hours ago"
+ Else:
+ "%Y-%m-%d %H:%M:%S"
+
+ """
+
+ assert isinstance(t, datetime)
+ if now is None:
+ now = datetime.now()
+ assert isinstance(now, datetime)
+ diff = now - t
+
+ second_diff = diff.seconds
+ day_diff = diff.days
+
+ # future (consider as just now)
+ if day_diff < 0:
+ return "just now"
+
+ # history
+ if day_diff == 0:
+ if second_diff < 10:
+ return "just now"
+ if second_diff < 60:
+ return str(second_diff) + " seconds ago"
+ if second_diff < 120:
+ return "a minute ago"
+ if second_diff < 3600:
+ return str(second_diff // 60) + " minutes ago"
+ if second_diff < 86400:
+ minutes = (second_diff % 3600) // 60
+ hours = second_diff // 3600
+ return "{0}:{1:02d} hours ago".format(hours, minutes)
+
+ return t.strftime(strftime)
+
+
+def pretty_timestamp(t, now=None):
+ """Parse timestamp to user readable format
+
+ >>> pretty_timestamp("20170614T151122Z", now="20170614T151123Z")
+ 'just now'
+
+ >>> pretty_timestamp("20170614T151122Z", now="20170614T171222Z")
+ '2:01 hours ago'
+
+ Args:
+ t (str): The time string to parse.
+ now (str, optional)
+
+ Returns:
+ str: human readable "recent" date.
+
+ """
+
+ if now is not None:
+ try:
+ now = time.strptime(now, "%Y%m%dT%H%M%SZ")
+ now = datetime.fromtimestamp(time.mktime(now))
+ except ValueError as e:
+ log.warning("Can't parse 'now' time format: {0} {1}".format(t, e))
+ return None
+
+ if isinstance(t, float):
+ dt = datetime.fromtimestamp(t)
+ else:
+ # Parse the time format as if it is `str` result from
+ # `pyblish.lib.time()` which usually is stored in Avalon database.
+ try:
+ t = time.strptime(t, "%Y%m%dT%H%M%SZ")
+ except ValueError as e:
+ log.warning("Can't parse time format: {0} {1}".format(t, e))
+ return None
+ dt = datetime.fromtimestamp(time.mktime(t))
+
+ # prettify
+ return pretty_date(dt, now=now)
+
+
+class PrettyTimeDelegate(QtWidgets.QStyledItemDelegate):
+ """A delegate that displays a timestamp as a pretty date.
+
+ This displays dates like `pretty_date`.
+
+ """
+
+ def displayText(self, value, locale):
+
+ if value is None:
+ # Ignore None value
+ return
+
+ return pretty_timestamp(value)
diff --git a/avalon/tools/loader/delegates.py b/avalon/tools/loader/delegates.py
deleted file mode 100644
index 67179c6cb..000000000
--- a/avalon/tools/loader/delegates.py
+++ /dev/null
@@ -1,102 +0,0 @@
-import time
-from datetime import datetime
-import logging
-
-from ...vendor.Qt import QtWidgets
-
-log = logging.getLogger(__name__)
-
-
-def pretty_date(t, now=None, strftime="%b %d %Y %H:%M"):
- """Parse datetime to readable timestamp
-
- Within first ten seconds:
- - "just now",
- Within first minute ago:
- - "%S seconds ago"
- Within one hour ago:
- - "%M minutes ago".
- Within one day ago:
- - "%H:%M hours ago"
- Else:
- "%Y-%m-%d %H:%M:%S"
-
- """
-
- assert isinstance(t, datetime)
- if now is None:
- now = datetime.now()
- assert isinstance(now, datetime)
- diff = now - t
-
- second_diff = diff.seconds
- day_diff = diff.days
-
- # future (consider as just now)
- if day_diff < 0:
- return "just now"
-
- # history
- if day_diff == 0:
- if second_diff < 10:
- return "just now"
- if second_diff < 60:
- return str(second_diff) + " seconds ago"
- if second_diff < 120:
- return "a minute ago"
- if second_diff < 3600:
- return str(second_diff // 60) + " minutes ago"
- if second_diff < 86400:
- minutes = (second_diff % 3600) // 60
- hours = second_diff // 3600
- return "{0}:{1:02d} hours ago".format(hours, minutes)
-
- return t.strftime(strftime)
-
-
-def pretty_timestamp(t, now=None):
- """Parse timestamp to user readable format
-
- >>> pretty_timestamp("20170614T151122Z", now="20170614T151123Z")
- 'just now'
-
- >>> pretty_timestamp("20170614T151122Z", now="20170614T171222Z")
- '2:01 hours ago'
-
- Args:
- t (str): The time string to parse.
- now (str, optional)
-
- Returns:
- str: human readable "recent" date.
-
- """
-
- if now is not None:
- try:
- now = time.strptime(now, "%Y%m%dT%H%M%SZ")
- now = datetime.fromtimestamp(time.mktime(now))
- except ValueError as e:
- log.warning("Can't parse 'now' time format: {0} {1}".format(t, e))
- return None
-
- try:
- t = time.strptime(t, "%Y%m%dT%H%M%SZ")
- except ValueError as e:
- log.warning("Can't parse time format: {0} {1}".format(t, e))
- return None
- dt = datetime.fromtimestamp(time.mktime(t))
-
- # prettify
- return pretty_date(dt, now=now)
-
-
-class PrettyTimeDelegate(QtWidgets.QStyledItemDelegate):
- """A delegate that displays a timestamp as a pretty date.
-
- This displays dates like `pretty_date`.
-
- """
-
- def displayText(self, value, locale):
- return pretty_timestamp(value)
diff --git a/avalon/tools/loader/widgets.py b/avalon/tools/loader/widgets.py
index 495de7207..031f8e11a 100644
--- a/avalon/tools/loader/widgets.py
+++ b/avalon/tools/loader/widgets.py
@@ -17,7 +17,7 @@
SubsetFilterProxyModel,
FamiliesFilterProxyModel,
)
-from .delegates import PrettyTimeDelegate
+from ..delegates import PrettyTimeDelegate
class SubsetWidget(QtWidgets.QWidget):
diff --git a/avalon/tools/models.py b/avalon/tools/models.py
index 513b3ffc3..471228e9b 100644
--- a/avalon/tools/models.py
+++ b/avalon/tools/models.py
@@ -270,7 +270,9 @@ def headerData(self, section, orientation, role):
# it is listing the tasks for
if role == QtCore.Qt.DisplayRole:
if orientation == QtCore.Qt.Horizontal:
- if section == 1: # count column
+ if section == 0:
+ return "Tasks"
+ elif section == 1: # count column
return "count ({0})".format(self._num_assets)
return super(TasksModel, self).headerData(section, orientation, role)
diff --git a/avalon/tools/widgets.py b/avalon/tools/widgets.py
index a144de51e..0e8bc0ca0 100644
--- a/avalon/tools/widgets.py
+++ b/avalon/tools/widgets.py
@@ -120,6 +120,11 @@ def get_active_asset(self):
current = self.view.currentIndex()
return current.data(self.model.ObjectIdRole)
+ def get_active_asset_document(self):
+ """Return the asset id the current asset."""
+ current = self.view.currentIndex()
+ return current.data(self.model.DocumentRole)
+
def get_active_index(self):
return self.view.currentIndex()
diff --git a/avalon/tools/workfiles/app.py b/avalon/tools/workfiles/app.py
index f6c5e6fba..0435736bc 100644
--- a/avalon/tools/workfiles/app.py
+++ b/avalon/tools/workfiles/app.py
@@ -3,86 +3,121 @@
import getpass
import re
import shutil
+import logging
+import platform
from ...vendor.Qt import QtWidgets, QtCore
-from ... import style, io, api
+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
+
+log = logging.getLogger(__name__)
module = sys.modules[__name__]
module.window = None
class NameWindow(QtWidgets.QDialog):
- """Name Window"""
+ """Name Window to define a unique filename inside a root folder
- def __init__(self, root):
- super(NameWindow, self).__init__()
- self.setWindowFlags(QtCore.Qt.FramelessWindowHint)
+ The filename will be based on the "workfile" template defined in the
+ project["config"]["template"].
- self.result = None
- self.setup(root)
-
- self.layout = QtWidgets.QVBoxLayout()
- self.setLayout(self.layout)
-
- grid_layout = QtWidgets.QGridLayout()
-
- label = QtWidgets.QLabel("Version:")
- grid_layout.addWidget(label, 0, 0)
- self.version_spinbox = QtWidgets.QSpinBox()
- self.version_spinbox.setMinimum(1)
- self.version_spinbox.setMaximum(9999)
- self.version_checkbox = QtWidgets.QCheckBox("Next Available Version")
- self.version_checkbox.setCheckState(QtCore.Qt.CheckState(2))
- layout = QtWidgets.QHBoxLayout()
- layout.addWidget(self.version_spinbox)
- layout.addWidget(self.version_checkbox)
- grid_layout.addLayout(layout, 0, 1)
- # Since the version can be padded with "{version:0>4}" we only search
- # for "{version".
- if "{version" not in self.template:
- label.setVisible(False)
- self.version_spinbox.setVisible(False)
- self.version_checkbox.setVisible(False)
+ """
- label = QtWidgets.QLabel("Comment:")
- grid_layout.addWidget(label, 1, 0)
- self.comment_lineedit = QtWidgets.QLineEdit()
- if "{comment}" not in self.template:
- label.setVisible(False)
- self.comment_lineedit.setVisible(False)
- grid_layout.addWidget(self.comment_lineedit, 1, 1)
+ def __init__(self, parent, root, session=None):
+ super(NameWindow, self).__init__(parent=parent)
+ self.setWindowFlags(self.windowFlags() | QtCore.Qt.FramelessWindowHint)
- grid_layout.addWidget(QtWidgets.QLabel("Preview:"), 2, 0)
- self.label = QtWidgets.QLabel("File name")
- grid_layout.addWidget(self.label, 2, 1)
+ self.result = None
+ self.host = api.registered_host()
+ self.root = root
+ self.work_file = None
- self.layout.addLayout(grid_layout)
+ if session is None:
+ # Fallback to active session
+ session = api.Session
- layout = QtWidgets.QHBoxLayout()
- self.ok_button = QtWidgets.QPushButton("Ok")
- layout.addWidget(self.ok_button)
- self.cancel_button = QtWidgets.QPushButton("Cancel")
- layout.addWidget(self.cancel_button)
- self.layout.addLayout(layout)
+ # Set work file data for template formatting
+ self.data = {
+ "project": io.find_one({"name": session["AVALON_PROJECT"],
+ "type": "project"}),
+ "asset": io.find_one({"name": session["AVALON_ASSET"],
+ "type": "asset"}),
+ "task": {
+ "name": session["AVALON_TASK"].lower(),
+ "label": session["AVALON_TASK"]
+ },
+ "version": 1,
+ "user": getpass.getuser(),
+ "comment": ""
+ }
- self.version_spinbox.valueChanged.connect(
+ # Define work files template
+ templates = self.data["project"]["config"]["template"]
+ template = templates.get("workfile",
+ "{task[name]}_v{version:0>4}<_{comment}>")
+ self.template = template
+
+ 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.version_checkbox.stateChanged.connect(
+ self.widgets["versionCheck"].stateChanged.connect(
self.on_version_checkbox_changed
)
- self.comment_lineedit.textChanged.connect(self.on_comment_changed)
- self.ok_button.pressed.connect(self.on_ok_pressed)
- self.cancel_button.pressed.connect(self.on_cancel_pressed)
+ 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.ok_button.setDefault(True)
+ 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.comment_lineedit.setFocus()
+ self.widgets["comment"].setFocus()
self.refresh()
@@ -146,11 +181,23 @@ def get_work_file(self, template=None):
return work_file
def refresh(self):
- if self.version_checkbox.isChecked():
- self.version_spinbox.setEnabled(False)
+
+ # 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)
# Find matching files
- files = os.listdir(self.root)
+ files = os.listdir(self.root) if os.path.exists(self.root) else []
# Fast match on extension
extensions = self.host.file_extensions()
@@ -166,10 +213,18 @@ def refresh(self):
template = self.get_work_file(template)
template = "^" + template + "$" # match beginning to end
+ # Match with ignore case on Windows due to the Windows
+ # OS not being case-sensitive. This avoids later running
+ # into the error that the file did exist if it existed
+ # with a different upper/lower-case.
+ kwargs = {}
+ if platform.system() == "Windows":
+ kwargs["flags"] = re.IGNORECASE
+
# Get highest version among existing matching files
version = 1
for file in sorted(files):
- match = re.match(template, file)
+ match = re.match(template, file, **kwargs)
if not match:
continue
@@ -186,201 +241,253 @@ def refresh(self):
"This is a bug, file exists: %s" % path
else:
- self.version_spinbox.setEnabled(True)
- self.data["version"] = self.version_spinbox.value()
+ self.widgets["versionValue"].setEnabled(True)
+ self.data["version"] = self.widgets["versionValue"].value()
self.work_file = self.get_work_file()
- self.label.setText(
+ 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)):
- self.label.setText(
+ preview.setText(
"Cannot create \"{0}\" because file exists!"
"".format(self.work_file)
)
- self.ok_button.setEnabled(False)
+ ok.setEnabled(False)
else:
- self.ok_button.setEnabled(True)
+ ok.setEnabled(True)
- def setup(self, root):
- self.root = root
- self.host = api.registered_host()
- # Get work file name
- self.data = {
- "project": io.find_one(
- {"name": api.Session["AVALON_PROJECT"], "type": "project"}
- ),
- "asset": io.find_one(
- {"name": api.Session["AVALON_ASSET"], "type": "asset"}
- ),
- "task": {
- "name": api.Session["AVALON_TASK"].lower(),
- "label": api.Session["AVALON_TASK"]
- },
- "version": 1,
- "user": getpass.getuser(),
- "comment": ""
+class TasksWidget(QtWidgets.QWidget):
+ """Widget showing active Tasks"""
+
+ task_changed = QtCore.Signal()
+
+ def __init__(self):
+ super(TasksWidget, self).__init__()
+ self.setContentsMargins(0, 0, 0, 0)
+
+ view = QtWidgets.QTreeView()
+ view.setIndentation(0)
+ model = TasksModel()
+ 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.template = "{task[name]}_v{version:0>4}<_{comment}>"
+ self.widgets = {
+ "view": view,
+ }
- templates = self.data["project"]["config"]["template"]
+ self._last_selected_task = None
- if "workfile" in templates:
- self.template = templates["workfile"]
+ def set_asset(self, asset_id):
- self.extensions = {"maya": ".ma", "nuke": ".nk"}
+ if asset_id 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
-class Window(QtWidgets.QDialog):
- """Work Files Window"""
+ self.models["tasks"].set_assets([asset_id])
- def __init__(self, root=None, parent=None):
- super(Window, self).__init__(parent)
- self.setWindowTitle("Work Files")
- self.setWindowFlags(QtCore.Qt.Window | QtCore.Qt.WindowCloseButtonHint)
+ if self._last_selected_task:
+ self.select_task(self._last_selected_task)
- self.root = root
+ # Force a task changed emit.
+ self.task_changed.emit()
- if self.root is None:
- self.root = os.getcwd()
+ def select_task(self, task):
+ """Select a task by name.
- self.host = api.registered_host()
+ If the task does not exist in the current model then selection is only
+ cleared.
- self.layout = QtWidgets.QVBoxLayout()
- self.setLayout(self.layout)
+ Args:
+ task (str): Name of the task to select.
- # Display current context
- # todo: context should update on update task
- label = u"Asset {0} \u25B6 Task {1}".format(
- api.Session["AVALON_ASSET"],
- api.Session["AVALON_TASK"]
- )
- self.context_label = QtWidgets.QLabel(label)
- self.context_label.setStyleSheet("QLabel{ font-size: 12pt; }")
- self.layout.addWidget(self.context_label)
-
- separator = QtWidgets.QFrame()
- separator.setFrameShape(QtWidgets.QFrame.HLine)
- separator.setFrameShadow(QtWidgets.QFrame.Plain)
- self.layout.addWidget(separator)
-
- self.list = QtWidgets.QListWidget()
- self.layout.addWidget(self.list)
-
- buttons_layout = QtWidgets.QHBoxLayout()
- self.duplicate_button = QtWidgets.QPushButton("Duplicate")
- buttons_layout.addWidget(self.duplicate_button)
- self.open_button = QtWidgets.QPushButton("Open")
- buttons_layout.addWidget(self.open_button)
- self.browse_button = QtWidgets.QPushButton("Browse")
- buttons_layout.addWidget(self.browse_button)
- self.layout.addLayout(buttons_layout)
-
- separator = QtWidgets.QFrame()
- separator.setFrameShape(QtWidgets.QFrame.HLine)
- separator.setFrameShadow(QtWidgets.QFrame.Sunken)
- self.layout.addWidget(separator)
+ """
- current_file = self.host.current_file()
- if current_file:
- current_label = os.path.basename(current_file)
- else:
- current_label = ""
- current_file_label = QtWidgets.QLabel("Current File: " + current_label)
- self.layout.addWidget(current_file_label)
-
- buttons_layout = QtWidgets.QHBoxLayout()
- self.save_as_button = QtWidgets.QPushButton("Save As")
- buttons_layout.addWidget(self.save_as_button)
- self.layout.addLayout(buttons_layout)
-
- self.duplicate_button.pressed.connect(self.on_duplicate_pressed)
- self.open_button.pressed.connect(self.on_open_pressed)
- self.list.doubleClicked.connect(self.on_open_pressed)
- self.browse_button.pressed.connect(self.on_browse_pressed)
- self.save_as_button.pressed.connect(self.on_save_as_pressed)
-
- self._name_window = None
- self._messagebox = None
+ # Clear selection
+ view = self.widgets["view"]
+ model = view.model()
+ selection_model = view.selectionModel()
+ selection_model.clearSelection()
- self.open_button.setFocus()
+ # 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)
- self.refresh()
- self.resize(400, 550)
+ # Set the currently active index
+ view.setCurrentIndex(index)
- def get_name(self):
+ def get_current_task(self):
+ """Return name of task at current index (selected)
- self._name_window = NameWindow(self.root)
- self._name_window.setStyleSheet(style.load_stylesheet())
- self._name_window.exec_()
+ Returns:
+ str: Name of the current task.
- return self._name_window.get_result()
+ """
+ view = self.widgets["view"]
+ index = view.currentIndex()
+ index = index.sibling(index.row(), 0) # ensure column zero for name
- def refresh(self):
- self.list.clear()
+ 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)
- modified = []
- extensions = set(self.host.file_extensions())
- for f in sorted(os.listdir(self.root)):
- path = os.path.join(self.root, f)
- if os.path.isdir(path):
- continue
- if extensions and os.path.splitext(f)[1] not in extensions:
- continue
+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)
- self.list.addItem(f)
- modified.append(os.path.getmtime(path))
+ # Setup
+ self._asset = None
+ self._task = None
+ self.root = None
+ self.host = api.registered_host()
- # Select last modified file
- if self.list.count():
- item = self.list.item(modified.index(max(modified)))
- item.setSelected(True)
+ # Whether to automatically select the latest modified
+ # file on a refresh of the files model.
+ self.auto_select_latest_modified = True
- # Scroll list so item is visible
- QtCore.QTimer.singleShot(100, lambda: self.list.scrollToItem(item))
+ # Avoid crash in Blender and store the message box
+ # (setting parent doesn't work as it hides the message box)
+ self._messagebox = None
- self.duplicate_button.setEnabled(True)
+ 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.duplicate_button.setEnabled(False)
+ self.model.set_root(None)
- self.list.setMinimumWidth(self.list.sizeHintForColumn(0) + 30)
+ def _get_session(self):
+ """Return a modified session for the current asset and task"""
- def save_as_maya(self, file_path):
- from maya import cmds
- cmds.file(rename=file_path)
- cmds.file(save=True, type="mayaAscii")
+ session = api.Session.copy()
+ changes = pipeline.compute_session_changes(session,
+ asset=self._asset,
+ task=self._task)
+ session.update(changes)
- def save_as_nuke(self, file_path):
- import nuke
- nuke.scriptSaveAs(file_path)
+ return session
- def save_changes_prompt(self):
- self._messagebox = QtWidgets.QMessageBox()
- self._messagebox.setWindowFlags(QtCore.Qt.FramelessWindowHint)
- self._messagebox.setIcon(self._messagebox.Warning)
- self._messagebox.setWindowTitle("Unsaved Changes!")
- self._messagebox.setText(
- "There are unsaved changes to the current file."
- "\nDo you want to save the changes?"
- )
- self._messagebox.setStandardButtons(
- self._messagebox.Yes | self._messagebox.No |
- self._messagebox.Cancel
- )
- result = self._messagebox.exec_()
+ def _enter_session(self):
+ """Enter the asset and task session currently selected"""
- if result == self._messagebox.Yes:
- return True
- elif result == self._messagebox.No:
- return False
- else:
- return None
+ 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
- def open(self, filepath):
+ 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()
@@ -390,24 +497,79 @@ def open(self, filepath):
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(host.current_file())
+ host.save_file(current_file)
else:
# Don't save, continue to open file
pass
+ self._enter_session()
return host.open_file(filepath)
+ 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_name()
+
+ work_file = self.get_filename()
if not work_file:
return
- src = os.path.join(
- self.root, self.list.selectedItems()[0].text()
- )
+ src = self._get_selected_filepath()
dst = os.path.join(
self.root, work_file
)
@@ -415,18 +577,25 @@ def on_duplicate_pressed(self):
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):
- selection = self.list.selectedItems()
- if not selection:
+ path = self._get_selected_filepath()
+ if not path:
print("No file selected to open..")
return
- work_file = os.path.join(self.root, selection[0].text())
-
- result = self.open(work_file)
- if result:
- self.close()
+ return self.open_file(path)
def on_browse_pressed(self):
@@ -439,27 +608,250 @@ def on_browse_pressed(self):
)[0]
if not work_file:
- self.refresh()
return
- self.open(work_file)
-
- self.close()
+ self.open_file(work_file)
def on_save_as_pressed(self):
- work_file = self.get_name()
+ 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()
- self.close()
+ 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"""
-def show(root=None, debug=False, parent=None):
+ 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 > 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(silo_creatable=False),
+ "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["assets"].silo_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 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"})
+
+ # Set silo
+ silo = asset_document.get("silo")
+ if self.widgets["assets"].get_current_silo() != silo:
+ self.widgets["assets"].set_silo(silo)
+
+ # Select the asset
+ self.widgets["assets"].select_assets([asset], expand=True)
+
+ # Force a refresh on Tasks?
+ self.widgets["tasks"].set_asset(asset_id=asset_document["_id"])
+
+ 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_active_asset()
+
+ if not asset:
+ # Force disable the other widgets if no
+ # active selection
+ self.widgets["tasks"].setEnabled(False)
+ self.widgets["files"].setEnabled(False)
+ else:
+ self.widgets["tasks"].setEnabled(True)
+
+ self.widgets["tasks"].set_asset(asset)
+
+ def _on_task_changed(self):
+
+ asset = self.widgets["assets"].get_active_asset_document()
+ 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 show(root=None, debug=False, parent=None, use_context=True):
"""Show Work Files GUI"""
+ # todo: remove `root` argument to show()
if module.window:
module.window.close()
@@ -485,22 +877,21 @@ def show(root=None, debug=False, parent=None):
raise RuntimeError("Host is missing required Work Files interfaces: "
"%s (host: %s)" % (", ".join(missing), host))
- # Allow to use a Host's default root.
- if root is None:
- root = host.work_root()
- if not root:
- raise ValueError("Root not given and no root returned by "
- "default from current host %s" % host.__name__)
-
- if not os.path.exists(root):
- raise OSError("Root set for Work Files app does not exist: %s" % root)
-
if debug:
api.Session["AVALON_ASSET"] = "Mock"
api.Session["AVALON_TASK"] = "Testing"
with tools_lib.application():
- window = Window(root, parent=parent)
+
+ 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.show()
window.setStyleSheet(style.load_stylesheet())
diff --git a/avalon/tools/workfiles/model.py b/avalon/tools/workfiles/model.py
new file mode 100644
index 000000000..484297bc0
--- /dev/null
+++ b/avalon/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/avalon/tools/workfiles/view.py b/avalon/tools/workfiles/view.py
new file mode 100644
index 000000000..624bfd18e
--- /dev/null
+++ b/avalon/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)
diff --git a/res/houdini/MainMenuCommon.XML b/res/houdini/MainMenuCommon.XML
index e288e876e..a492ef7cd 100644
--- a/res/houdini/MainMenuCommon.XML
+++ b/res/houdini/MainMenuCommon.XML
@@ -3,23 +3,13 @@