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 @@ - + - - - - - - + -