From 6ae6742b92f3043a78f212f3162fb0f8a66c8afa Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 6 Sep 2019 16:11:02 +0200 Subject: [PATCH 01/32] Work in progress of Context Manager + Work Files merge (WIP) --- avalon/tools/workfiles/app.py | 225 +++++++++++++++++++++++----------- 1 file changed, 155 insertions(+), 70 deletions(-) diff --git a/avalon/tools/workfiles/app.py b/avalon/tools/workfiles/app.py index f2a33f663..4b06038a2 100644 --- a/avalon/tools/workfiles/app.py +++ b/avalon/tools/workfiles/app.py @@ -8,6 +8,8 @@ from ... import style, io, api from .. import lib as parentlib +from ..widgets import AssetWidget +from ..models import TasksModel class NameWindow(QtWidgets.QDialog): @@ -231,81 +233,156 @@ def setup(self, root): self.extensions = {"maya": ".ma", "nuke": ".nk"} -class Window(QtWidgets.QDialog): +class ContextBreadcrumb(QtWidgets.QWidget): + """Horizontal widget showing current avalon project, asset and task.""" + + def __init__(self, *args): + QtWidgets.QWidget.__init__(self, *args) + + self.widgets = { + "project": QtWidgets.QLabel(), + "asset": QtWidgets.QLabel(), + "task": QtWidgets.QLabel() + } + + layout = QtWidgets.QHBoxLayout(self) + layout.addWidget(self.widgets["project"]) + layout.addWidget(QtWidgets.QLabel(u"\u25B6")) + layout.addWidget(self.widgets["asset"]) + layout.addWidget(QtWidgets.QLabel(u"\u25B6")) + layout.addWidget(self.widgets["task"]) + layout.addStretch() + + for name in ["project", "asset", "task"]: + self.widgets[name].setStyleSheet("QLabel{ font-size: 12pt; }") + + self.refresh() # initialize + + def refresh(self): + + self.widgets["project"].setText(api.Session["AVALON_PROJECT"]) + self.widgets["asset"].setText(api.Session["AVALON_ASSET"]) + self.widgets["task"].setText(api.Session["AVALON_TASK"]) + + +class TasksWidget(QtWidgets.QWidget): + 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.addWidget(view) + + # Hide the default tasks "count" as we don't need that data here. + view.setColumnHidden(1, True) + + self.models = { + "tasks": model + } + + self.widgets = { + "view": view, + } + + +class Window(QtWidgets.QMainWindow): """Work Files Window""" + title = "Work Files" def __init__(self, root=None): super(Window, self).__init__() - self.setWindowTitle("Work Files") + self.setWindowTitle(self.title) self.setWindowFlags(QtCore.Qt.WindowCloseButtonHint) + # Setup self.root = root - if self.root is None: self.root = os.getcwd() self.host = api.registered_host() - self.layout = QtWidgets.QVBoxLayout() - self.setLayout(self.layout) + pages = { + "home": QtWidgets.QWidget() + } - # 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) + widgets = { + "pages": QtWidgets.QStackedWidget(), + "header": ContextBreadcrumb(), + "body": QtWidgets.QWidget(), + "assets": AssetWidget(), + "tasks": TasksWidget(), + "files": QtWidgets.QWidget(), + "fileList": QtWidgets.QListWidget(), + "fileDuplicate": QtWidgets.QPushButton("Duplicate"), + "fileOpen": QtWidgets.QPushButton("Open"), + "fileBrowse": QtWidgets.QPushButton("Browse"), + "fileCurrent": QtWidgets.QLabel(), + "fileSave": QtWidgets.QPushButton("Save As") + } - separator = QtWidgets.QFrame() - separator.setFrameShape(QtWidgets.QFrame.HLine) - separator.setFrameShadow(QtWidgets.QFrame.Plain) - self.layout.addWidget(separator) + self.setCentralWidget(widgets["pages"]) + widgets["pages"].addWidget(pages["home"]) - self.list = QtWidgets.QListWidget() - self.layout.addWidget(self.list) + # Build homepage + layout = QtWidgets.QVBoxLayout(pages["home"]) + layout.addWidget(widgets["header"]) + layout.addWidget(widgets["body"]) - 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) + # Build body + layout = QtWidgets.QHBoxLayout(widgets["body"]) + layout.addWidget(widgets["assets"]) + layout.addWidget(widgets["tasks"]) + layout.addWidget(widgets["files"]) + + # Build buttons widget for files widget + buttons = QtWidgets.QWidget() + layout = QtWidgets.QHBoxLayout(buttons) + layout.addWidget(widgets["fileDuplicate"]) + layout.addWidget(widgets["fileOpen"]) + layout.addWidget(widgets["fileBrowse"]) + + # Build files widgets + layout = QtWidgets.QVBoxLayout(widgets["files"]) + layout.addWidget(widgets["fileList"]) + layout.addWidget(buttons) separator = QtWidgets.QFrame() separator.setFrameShape(QtWidgets.QFrame.HLine) - separator.setFrameShadow(QtWidgets.QFrame.Sunken) - self.layout.addWidget(separator) + separator.setFrameShadow(QtWidgets.QFrame.Plain) + 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) + layout.addWidget(widgets["fileCurrent"]) + layout.addWidget(widgets["fileSave"]) + + widgets["fileDuplicate"].pressed.connect(self.on_duplicate_pressed) + widgets["fileOpen"].pressed.connect(self.on_open_pressed) + widgets["fileList"].doubleClicked.connect(self.on_open_pressed) + widgets["fileBrowse"].pressed.connect(self.on_browse_pressed) + widgets["fileSave"].pressed.connect(self.on_save_as_pressed) - buttons_layout = QtWidgets.QHBoxLayout() - self.save_as_button = QtWidgets.QPushButton("Save As") - buttons_layout.addWidget(self.save_as_button) - self.layout.addLayout(buttons_layout) + # Force focus on the open button by default, required for Houdini. + widgets["fileOpen"].setFocus() - 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) + # Connect signals + widgets["assets"].current_changed.connect(self.on_asset_changed) - self.open_button.setFocus() + self.widgets = widgets self.refresh() self.resize(400, 550) - def get_name(self): + def get_filename(self): + """Show save dialog to define filename for save or duplicate + + Returns: + str: The filename to create. + + """ window = NameWindow(self.root) window.setStyleSheet(style.load_stylesheet()) window.exec_() @@ -313,7 +390,17 @@ def get_name(self): return window.get_result() def refresh(self): - self.list.clear() + + # Refresh asset widget + self.widgets["assets"].refresh() + + # Refresh current scene label + current = self.host.current_file() or "" + self.widgets["fileCurrent"].setText("Current File: %s" % current) + + # Refresh files list + list = self.widgets["fileList"] + list.clear() modified = [] extensions = set(self.host.file_extensions()) @@ -325,31 +412,23 @@ def refresh(self): if extensions and os.path.splitext(f)[1] not in extensions: continue - self.list.addItem(f) + list.addItem(f) modified.append(os.path.getmtime(path)) # Select last modified file - if self.list.count(): - item = self.list.item(modified.index(max(modified))) + if list.count(): + item = list.item(modified.index(max(modified))) item.setSelected(True) # Scroll list so item is visible - QtCore.QTimer.singleShot(100, lambda: self.list.scrollToItem(item)) + callback = lambda: self.widgets["fileList"].scrollToItem(item) + QtCore.QTimer.singleShot(100, callback) - self.duplicate_button.setEnabled(True) + self.widgets["fileDuplicate"].setEnabled(True) else: - self.duplicate_button.setEnabled(False) - - self.list.setMinimumWidth(self.list.sizeHintForColumn(0) + 30) - - def save_as_maya(self, file_path): - from maya import cmds - cmds.file(rename=file_path) - cmds.file(save=True, type="mayaAscii") + self.widgets["fileDuplicate"].setEnabled(False) - def save_as_nuke(self, file_path): - import nuke - nuke.scriptSaveAs(file_path) + list.setMinimumWidth(list.sizeHintForColumn(0) + 30) def save_changes_prompt(self): messagebox = QtWidgets.QMessageBox() @@ -392,13 +471,13 @@ def open(self, filepath): return host.open(filepath) 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() + self.root, self.widgets["fileList"].selectedItems()[0].text() ) dst = os.path.join( self.root, work_file @@ -409,7 +488,7 @@ def on_duplicate_pressed(self): def on_open_pressed(self): - selection = self.list.selectedItems() + selection = self.widgets["fileList"].selectedItems() if not selection: print("No file selected to open..") return @@ -439,7 +518,7 @@ def on_browse_pressed(self): self.close() def on_save_as_pressed(self): - work_file = self.get_name() + work_file = self.get_filename() if not work_file: return @@ -449,6 +528,10 @@ def on_save_as_pressed(self): self.close() + def on_asset_changed(self): + asset = self.widgets["assets"].get_active_asset() + self.widgets["tasks"].models["tasks"].set_assets([asset]) + def show(root=None, debug=False): """Show Work Files GUI""" @@ -488,6 +571,7 @@ def show(root=None, debug=False): api.Session["AVALON_TASK"] = "Testing" with parentlib.application(): + global window window = Window(root) window.setStyleSheet(style.load_stylesheet()) @@ -497,4 +581,5 @@ def show(root=None, debug=False): else: # Cause modal dialog - window.exec_() + # todo: force modal again + window.show() From ecf15b16cf87acc290a80de5f72fd201c3db3cae Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 9 Sep 2019 09:41:56 +0200 Subject: [PATCH 02/32] Preserve last active task in TaskWidget as asset is switched --- avalon/tools/workfiles/app.py | 85 +++++++++++++++++++++++++++++++++-- 1 file changed, 81 insertions(+), 4 deletions(-) diff --git a/avalon/tools/workfiles/app.py b/avalon/tools/workfiles/app.py index 4b06038a2..06116d9a9 100644 --- a/avalon/tools/workfiles/app.py +++ b/avalon/tools/workfiles/app.py @@ -276,6 +276,7 @@ def __init__(self): 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. @@ -289,6 +290,62 @@ def __init__(self): "view": view, } + self._last_selected_task = None + + def set_asset(self, asset_id): + + # Try and preserve the last selected task and reselect it + # after switching assets. If there's no currently selected + # asset keep whatever the "last selected" was prior to it. + current = self.get_current_task() + if current: + self._last_selected_task = current + + self.models["tasks"].set_assets([asset_id]) + + if self._last_selected_task: + self.select_task(self._last_selected_task) + + def select_task(self, task): + """Select a task by name. + + If the task does not exist in the current model then selection is only + cleared. + + Args: + task (str): Name of the task to select. + + """ + + # Clear selection + view = self.widgets["view"] + model = view.model() + selection_model = view.selectionModel() + selection_model.clearSelection() + + # Select the task + mode = selection_model.Select | selection_model.Rows + for row in range(model.rowCount(QtCore.QModelIndex())): + index = model.index(row, 0, QtCore.QModelIndex()) + name = index.data(QtCore.Qt.DisplayRole) + if name == task: + selection_model.select(index, mode) + + # Set the currently active index + view.setCurrentIndex(index) + + def get_current_task(self): + """Return name of task at current index (selected) + + Returns: + str: Name of the current task. + + """ + view = self.widgets["view"] + index = view.currentIndex() + index = index.sibling(index.row(), 0) # ensure column zero for name + return index.data(QtCore.Qt.DisplayRole) + class Window(QtWidgets.QMainWindow): """Work Files Window""" @@ -314,7 +371,7 @@ def __init__(self, root=None): "pages": QtWidgets.QStackedWidget(), "header": ContextBreadcrumb(), "body": QtWidgets.QWidget(), - "assets": AssetWidget(), + "assets": AssetWidget(silo_creatable=False), "tasks": TasksWidget(), "files": QtWidgets.QWidget(), "fileList": QtWidgets.QListWidget(), @@ -342,6 +399,7 @@ def __init__(self, root=None): # Build buttons widget for files widget buttons = QtWidgets.QWidget() layout = QtWidgets.QHBoxLayout(buttons) + layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(widgets["fileDuplicate"]) layout.addWidget(widgets["fileOpen"]) layout.addWidget(widgets["fileBrowse"]) @@ -374,7 +432,25 @@ def __init__(self, root=None): self.widgets = widgets self.refresh() - self.resize(400, 550) + self.resize(750, 500) + + 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["data"].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) + + if "task" in context: + self.widgets["tasks"].select_task(context["task"]) def get_filename(self): """Show save dialog to define filename for save or duplicate @@ -530,8 +606,7 @@ def on_save_as_pressed(self): def on_asset_changed(self): asset = self.widgets["assets"].get_active_asset() - self.widgets["tasks"].models["tasks"].set_assets([asset]) - + self.widgets["tasks"].set_asset(asset) def show(root=None, debug=False): """Show Work Files GUI""" @@ -578,8 +653,10 @@ def show(root=None, debug=False): if debug: # Enable closing in standalone window.show() + return window else: # Cause modal dialog # todo: force modal again window.show() + return window From 94b317e7ad07edd97ca745502770a98de839fc3a Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 9 Sep 2019 11:14:56 +0200 Subject: [PATCH 03/32] Refactor NameWindow code according to contribution guidelines --- avalon/tools/workfiles/app.py | 191 ++++++++++++++++++---------------- 1 file changed, 104 insertions(+), 87 deletions(-) diff --git a/avalon/tools/workfiles/app.py b/avalon/tools/workfiles/app.py index 06116d9a9..b8340cafc 100644 --- a/avalon/tools/workfiles/app.py +++ b/avalon/tools/workfiles/app.py @@ -13,75 +13,108 @@ class NameWindow(QtWidgets.QDialog): - """Name Window""" + """Name Window to define a unique filename inside a root folder + + The filename will be based on the "workfile" template defined in the + project["config"]["template"]. + + """ def __init__(self, root): super(NameWindow, self).__init__() self.setWindowFlags(QtCore.Qt.FramelessWindowHint) 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) + self.root = root + self.host = api.registered_host() + self.work_file = None - 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) + # 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": "" + } - grid_layout.addWidget(QtWidgets.QLabel("Preview:"), 2, 0) - self.label = QtWidgets.QLabel("File name") - grid_layout.addWidget(self.label, 2, 1) + # Define work files template + templates = self.data["project"]["config"]["template"] + template = templates.get("workfile", + "{task[name]}_v{version:0>4}<_{comment}>") + self.template = template + + # Build version + version = QtWidgets.QWidget() + version_spinbox = QtWidgets.QSpinBox() + version_spinbox.setMinimum(1) + version_spinbox.setMaximum(9999) + version_checkbox = QtWidgets.QCheckBox("Next Available Version") + version_checkbox.setCheckState(QtCore.Qt.CheckState(2)) + layout = QtWidgets.QHBoxLayout(version) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(version_spinbox) + layout.addWidget(version_checkbox) - self.layout.addLayout(grid_layout) + # Build comment + comment = QtWidgets.QLineEdit() - 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) + # Build preview + preview = QtWidgets.QLabel("Preview filename") - self.version_spinbox.valueChanged.connect( + # Build buttons + buttons = QtWidgets.QWidget() + layout = QtWidgets.QHBoxLayout(buttons) + ok_button = QtWidgets.QPushButton("Ok") + cancel_button = QtWidgets.QPushButton("Cancel") + layout.addWidget(ok_button) + layout.addWidget(cancel_button) + + # Build inputs + inputs = QtWidgets.QWidget() + layout = QtWidgets.QFormLayout(inputs) + layout.addRow("Version:", version) + layout.addRow("Comment:", comment) + layout.addRow("Preview:", preview) + + # Build layout + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(inputs) + layout.addWidget(buttons) + + version_spinbox.valueChanged.connect( self.on_version_spinbox_changed ) - self.version_checkbox.stateChanged.connect( + version_checkbox.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) + comment.textChanged.connect(self.on_comment_changed) + ok_button.pressed.connect(self.on_ok_pressed) + cancel_button.pressed.connect(self.on_cancel_pressed) # Allow "Enter" key to accept the save. - self.ok_button.setDefault(True) + ok_button.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() + comment.setFocus() + + self.widgets = { + "preview": preview, + "comment": comment, + "versionValue": version_spinbox, + "versionCheck": version_checkbox, + "okButton": ok_button, + "cancelButton": cancel_button + } self.refresh() @@ -145,8 +178,19 @@ 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: + version.setVisible(False) + + # Build comment + comment = QtWidgets.QLineEdit() + if "{comment}" not in self.template: + comment.setVisible(False) + + if self.widgets["versionCheck"].isChecked(): + self.widgets["versionValue"].setEnabled(False) # Find matching files files = os.listdir(self.root) @@ -185,52 +229,24 @@ 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) - - 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": "" - } - - self.template = "{task[name]}_v{version:0>4}<_{comment}>" - - templates = self.data["project"]["config"]["template"] - - if "workfile" in templates: - self.template = templates["workfile"] - - self.extensions = {"maya": ".ma", "nuke": ".nk"} + ok.setEnabled(True) class ContextBreadcrumb(QtWidgets.QWidget): @@ -608,6 +624,7 @@ def on_asset_changed(self): asset = self.widgets["assets"].get_active_asset() self.widgets["tasks"].set_asset(asset) + def show(root=None, debug=False): """Show Work Files GUI""" From 5d0b9239b0dc195ac65befc7b6bcebeaa7c7b490 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 9 Sep 2019 11:24:55 +0200 Subject: [PATCH 04/32] Set parent for NameWindow and fix FramelessWindowHint flags --- avalon/tools/workfiles/app.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/avalon/tools/workfiles/app.py b/avalon/tools/workfiles/app.py index b8340cafc..c847599e4 100644 --- a/avalon/tools/workfiles/app.py +++ b/avalon/tools/workfiles/app.py @@ -20,9 +20,9 @@ class NameWindow(QtWidgets.QDialog): """ - def __init__(self, root): - super(NameWindow, self).__init__() - self.setWindowFlags(QtCore.Qt.FramelessWindowHint) + def __init__(self, parent, root): + super(NameWindow, self).__init__(parent=parent) + self.setWindowFlags(self.windowFlags() | QtCore.Qt.FramelessWindowHint) self.result = None self.root = root @@ -110,6 +110,7 @@ def __init__(self, root): self.widgets = { "preview": preview, "comment": comment, + "version": version, "versionValue": version_spinbox, "versionCheck": version_checkbox, "okButton": ok_button, @@ -182,12 +183,13 @@ def refresh(self): # Since the version can be padded with "{version:0>4}" we only search # for "{version". if "{version" not in self.template: - version.setVisible(False) + # todo: hide the full row + self.widgets["version"].setVisible(False) # Build comment - comment = QtWidgets.QLineEdit() if "{comment}" not in self.template: - comment.setVisible(False) + # todo: hide the full row + self.widgets["comment"].setVisible(False) if self.widgets["versionCheck"].isChecked(): self.widgets["versionValue"].setEnabled(False) @@ -475,8 +477,8 @@ def get_filename(self): str: The filename to create. """ - window = NameWindow(self.root) - window.setStyleSheet(style.load_stylesheet()) + window = NameWindow(parent=self, + root=self.root) window.exec_() return window.get_result() From 0c9a5c2336f227f3b83987f7bc9cdb0684729464 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 10 Sep 2019 14:19:48 +0200 Subject: [PATCH 05/32] Include icons for breadcrumbs, log warning on missing work root path - The root is now not passed to the Window anymore, but always retrieved using the Work Files API of the host with host.work_root() --- avalon/tools/workfiles/app.py | 122 +++++++++++++++++++++++++--------- 1 file changed, 90 insertions(+), 32 deletions(-) diff --git a/avalon/tools/workfiles/app.py b/avalon/tools/workfiles/app.py index c847599e4..858f41780 100644 --- a/avalon/tools/workfiles/app.py +++ b/avalon/tools/workfiles/app.py @@ -3,14 +3,18 @@ import getpass import re import shutil +import logging from ...vendor.Qt import QtWidgets, QtCore +from ...vendor import qtawesome from ... import style, io, api from .. import lib as parentlib from ..widgets import AssetWidget from ..models import TasksModel +log = logging.getLogger(__name__) + class NameWindow(QtWidgets.QDialog): """Name Window to define a unique filename inside a root folder @@ -25,8 +29,8 @@ def __init__(self, parent, root): self.setWindowFlags(self.windowFlags() | QtCore.Qt.FramelessWindowHint) self.result = None - self.root = root self.host = api.registered_host() + self.root = self.host.work_root() self.work_file = None # Get work file name @@ -257,30 +261,53 @@ class ContextBreadcrumb(QtWidgets.QWidget): def __init__(self, *args): QtWidgets.QWidget.__init__(self, *args) + self.context = {} self.widgets = { + "projectIcon": QtWidgets.QLabel(), + "assetIcon": QtWidgets.QLabel(), "project": QtWidgets.QLabel(), "asset": QtWidgets.QLabel(), - "task": QtWidgets.QLabel() + "task": QtWidgets.QLabel(), + "taskIcon": QtWidgets.QLabel(), } layout = QtWidgets.QHBoxLayout(self) + layout.addWidget(self.widgets["projectIcon"]) layout.addWidget(self.widgets["project"]) layout.addWidget(QtWidgets.QLabel(u"\u25B6")) + layout.addWidget(self.widgets["assetIcon"]) layout.addWidget(self.widgets["asset"]) layout.addWidget(QtWidgets.QLabel(u"\u25B6")) + layout.addWidget(self.widgets["taskIcon"]) layout.addWidget(self.widgets["task"]) layout.addStretch() for name in ["project", "asset", "task"]: self.widgets[name].setStyleSheet("QLabel{ font-size: 12pt; }") - self.refresh() # initialize - def refresh(self): - self.widgets["project"].setText(api.Session["AVALON_PROJECT"]) - self.widgets["asset"].setText(api.Session["AVALON_ASSET"]) - self.widgets["task"].setText(api.Session["AVALON_TASK"]) + self.context = { + "project": api.Session["AVALON_PROJECT"], + "asset": api.Session["AVALON_ASSET"], + "task": api.Session["AVALON_TASK"] + } + + # Refresh labels + for key, value in self.context.items(): + self.widgets[key].setText(value) + + # todo: match icons from database when supplied + icons = { + "projectIcon": "fa.map", + "assetIcon": "fa.plus-square", + "taskIcon": "fa.male" + } + + for key, value in icons.items(): + icon = qtawesome.icon(value, + color=style.colors.default).pixmap(18, 18) + self.widgets[key].setPixmap(icon) class TasksWidget(QtWidgets.QWidget): @@ -365,20 +392,23 @@ def get_current_task(self): return index.data(QtCore.Qt.DisplayRole) +class FilesWidget(QtWidgets.QWidget): + """A widget displaying files that allows to save""" + # todo: separate out the files display from window + pass + + class Window(QtWidgets.QMainWindow): """Work Files Window""" title = "Work Files" - def __init__(self, root=None): + def __init__(self): super(Window, self).__init__() self.setWindowTitle(self.title) self.setWindowFlags(QtCore.Qt.WindowCloseButtonHint) # Setup - self.root = root - if self.root is None: - self.root = os.getcwd() - + self.root = None self.host = api.registered_host() pages = { @@ -438,6 +468,9 @@ def __init__(self, root=None): widgets["fileDuplicate"].pressed.connect(self.on_duplicate_pressed) widgets["fileOpen"].pressed.connect(self.on_open_pressed) widgets["fileList"].doubleClicked.connect(self.on_open_pressed) + widgets["tasks"].widgets["view"].doubleClicked.connect( + self.on_task_pressed + ) widgets["fileBrowse"].pressed.connect(self.on_browse_pressed) widgets["fileSave"].pressed.connect(self.on_save_as_pressed) @@ -485,29 +518,38 @@ def get_filename(self): def refresh(self): + self.root = self.host.work_root() + # Refresh asset widget self.widgets["assets"].refresh() # Refresh current scene label - current = self.host.current_file() or "" + current = os.path.basename(self.host.current_file()) or "" self.widgets["fileCurrent"].setText("Current File: %s" % current) + # Refresh breadcrumbs + self.widgets["header"].refresh() + # Refresh files list list = self.widgets["fileList"] list.clear() 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 + if os.path.exists(self.root): + 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 - list.addItem(f) - modified.append(os.path.getmtime(path)) + list.addItem(f) + modified.append(os.path.getmtime(path)) + else: + log.warning("Work root does not exist: %s" % self.root) # Select last modified file if list.count(): @@ -620,15 +662,41 @@ def on_save_as_pressed(self): file_path = os.path.join(self.root, work_file) self.host.save(file_path) + self.refresh() + self.close() def on_asset_changed(self): asset = self.widgets["assets"].get_active_asset() self.widgets["tasks"].set_asset(asset) + def on_task_pressed(self): + + asset_id = self.widgets["assets"].get_active_asset() + if not asset_id: + log.warning("No asset selected.") + return + + task_name = self.widgets["tasks"].get_current_task() + if not task_name: + log.warning("No task selected.") + return + + # Get the asset name from asset id. + asset = io.find_one({"_id": io.ObjectId(asset_id), "type": "asset"}) + if not asset: + log.error("Invalid asset id: %s" % asset_id) + return + + asset_name = asset["name"] + api.update_current_task(task=task_name, asset=asset_name) + + self.refresh() + def show(root=None, debug=False): """Show Work Files GUI""" + # todo: remove `root` argument to show() host = api.registered_host() if host is None: @@ -650,23 +718,13 @@ def show(root=None, debug=False): 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 parentlib.application(): global window - window = Window(root) + window = Window() window.setStyleSheet(style.load_stylesheet()) if debug: From eb5f152c96e9ebebead241709287f6a5084be487 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 10 Sep 2019 14:33:14 +0200 Subject: [PATCH 06/32] Use def as opposed to assigning a lambda expression --- avalon/tools/workfiles/app.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/avalon/tools/workfiles/app.py b/avalon/tools/workfiles/app.py index 411600671..64d62f883 100644 --- a/avalon/tools/workfiles/app.py +++ b/avalon/tools/workfiles/app.py @@ -557,7 +557,10 @@ def refresh(self): item.setSelected(True) # Scroll list so item is visible - callback = lambda: self.widgets["fileList"].scrollToItem(item) + def callback(): + """Delayed callback to scroll to the item""" + self.widgets["fileList"].scrollToItem(item) + QtCore.QTimer.singleShot(100, callback) self.widgets["fileDuplicate"].setEnabled(True) From b76a1ae4dd2d874524fa4d7cb78e80984ec60f9c Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 10 Sep 2019 15:46:21 +0200 Subject: [PATCH 07/32] Refactor `list` to `file_list` to avoid shadowing built-in `list` --- avalon/tools/workfiles/app.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/avalon/tools/workfiles/app.py b/avalon/tools/workfiles/app.py index 64d62f883..06e1feb0d 100644 --- a/avalon/tools/workfiles/app.py +++ b/avalon/tools/workfiles/app.py @@ -531,8 +531,8 @@ def refresh(self): self.widgets["header"].refresh() # Refresh files list - list = self.widgets["fileList"] - list.clear() + file_list = self.widgets["fileList"] + file_list.clear() modified = [] extensions = set(self.host.file_extensions()) @@ -546,14 +546,14 @@ def refresh(self): if extensions and os.path.splitext(f)[1] not in extensions: continue - list.addItem(f) + file_list.addItem(f) modified.append(os.path.getmtime(path)) else: log.warning("Work root does not exist: %s" % self.root) # Select last modified file - if list.count(): - item = list.item(modified.index(max(modified))) + if file_list.count(): + item = file_list.item(modified.index(max(modified))) item.setSelected(True) # Scroll list so item is visible @@ -567,7 +567,7 @@ def callback(): else: self.widgets["fileDuplicate"].setEnabled(False) - list.setMinimumWidth(list.sizeHintForColumn(0) + 30) + file_list.setMinimumWidth(file_list.sizeHintForColumn(0) + 30) def save_changes_prompt(self): messagebox = QtWidgets.QMessageBox() From 7b8499d9c1fc3d5cd8b84f364e6f34bd14309119 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 12 Sep 2019 18:12:58 +0200 Subject: [PATCH 08/32] First declare widgets to self.widgets in NameWindow, match Window code --- avalon/tools/workfiles/app.py | 82 +++++++++++++++-------------------- 1 file changed, 36 insertions(+), 46 deletions(-) diff --git a/avalon/tools/workfiles/app.py b/avalon/tools/workfiles/app.py index 06e1feb0d..1947cfc69 100644 --- a/avalon/tools/workfiles/app.py +++ b/avalon/tools/workfiles/app.py @@ -56,70 +56,59 @@ def __init__(self, parent, root): "{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 - version = QtWidgets.QWidget() - version_spinbox = QtWidgets.QSpinBox() - version_spinbox.setMinimum(1) - version_spinbox.setMaximum(9999) - version_checkbox = QtWidgets.QCheckBox("Next Available Version") - version_checkbox.setCheckState(QtCore.Qt.CheckState(2)) - layout = QtWidgets.QHBoxLayout(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(version_spinbox) - layout.addWidget(version_checkbox) - - # Build comment - comment = QtWidgets.QLineEdit() - - # Build preview - preview = QtWidgets.QLabel("Preview filename") + layout.addWidget(self.widgets["versionValue"]) + layout.addWidget(self.widgets["versionCheck"]) # Build buttons - buttons = QtWidgets.QWidget() - layout = QtWidgets.QHBoxLayout(buttons) - ok_button = QtWidgets.QPushButton("Ok") - cancel_button = QtWidgets.QPushButton("Cancel") - layout.addWidget(ok_button) - layout.addWidget(cancel_button) + layout = QtWidgets.QHBoxLayout(self.widgets["buttons"]) + layout.addWidget(self.widgets["okButton"]) + layout.addWidget(self.widgets["cancelButton"]) # Build inputs - inputs = QtWidgets.QWidget() - layout = QtWidgets.QFormLayout(inputs) - layout.addRow("Version:", version) - layout.addRow("Comment:", comment) - layout.addRow("Preview:", preview) + 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(inputs) - layout.addWidget(buttons) + layout.addWidget(self.widgets["inputs"]) + layout.addWidget(self.widgets["buttons"]) - version_spinbox.valueChanged.connect( + self.widgets["versionValue"].valueChanged.connect( self.on_version_spinbox_changed ) - version_checkbox.stateChanged.connect( + self.widgets["versionCheck"].stateChanged.connect( self.on_version_checkbox_changed ) - comment.textChanged.connect(self.on_comment_changed) - ok_button.pressed.connect(self.on_ok_pressed) - 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. - 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) - comment.setFocus() - - self.widgets = { - "preview": preview, - "comment": comment, - "version": version, - "versionValue": version_spinbox, - "versionCheck": version_checkbox, - "okButton": ok_button, - "cancelButton": cancel_button - } + self.widgets["comment"].setFocus() self.refresh() @@ -524,7 +513,8 @@ def refresh(self): self.widgets["assets"].refresh() # Refresh current scene label - current = os.path.basename(self.host.current_file()) or "" + filepath = self.host.current_file() + current = os.path.basename(filepath) if filepath else "" self.widgets["fileCurrent"].setText("Current File: %s" % current) # Refresh breadcrumbs From 7ad657a075856b38dd95c21fcfbcbe8fa0060a4c Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 17 Dec 2019 20:49:44 +0100 Subject: [PATCH 09/32] Improve merge of Context Manager and Work Files tool --- avalon/fusion/workio.py | 7 +- avalon/houdini/workio.py | 7 +- avalon/maya/workio.py | 31 ++- avalon/nuke/workio.py | 11 +- avalon/pipeline.py | 108 +++++--- avalon/tools/widgets.py | 5 + avalon/tools/workfiles/app.py | 494 +++++++++++++++++++++------------- 7 files changed, 426 insertions(+), 237 deletions(-) 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/workio.py b/avalon/maya/workio.py index c515bfdc0..d591c7b27 100644 --- a/avalon/maya/workio.py +++ b/avalon/maya/workio.py @@ -30,10 +30,27 @@ 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 + 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 = line[len(scene_rule):] # == "rule"; + scene_dir = remainder.split('"')[1] # == rule + 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 \ No newline at end of file diff --git a/avalon/nuke/workio.py b/avalon/nuke/workio.py index a0dbe091a..49efc1407 100644 --- a/avalon/nuke/workio.py +++ b/avalon/nuke/workio.py @@ -42,6 +42,11 @@ 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): + + 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..965eae6d6 100644 --- a/avalon/pipeline.py +++ b/avalon/pipeline.py @@ -926,61 +926,103 @@ 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. """ - 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 = dict() - # 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"] + # If no changes, return directly + if not any([task, asset, app]): + return changes + + if task: + changes["AVALON_TASK"] = task + + if app: + changes["AVALON_APP"] = app + + # Update silo and hierarchy when asset changed + if asset: + if isinstance(asset, dict): + # Assume database document + asset_document = asset + asset = asset["name"] + else: + asset_document = io.find_one({"name": asset, + "type": "asset"}) + assert asset_document, "Asset must exist" + + changes["AVALON_ASSET"] = asset + + # 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/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 f0801cbe0..91ee58084 100644 --- a/avalon/tools/workfiles/app.py +++ b/avalon/tools/workfiles/app.py @@ -4,10 +4,11 @@ import re import shutil import logging +import platform from ...vendor.Qt import QtWidgets, QtCore from ...vendor import qtawesome -from ... import style, io, api +from ... import style, io, api, pipeline from .. import lib as tools_lib from ..widgets import AssetWidget @@ -27,26 +28,30 @@ class NameWindow(QtWidgets.QDialog): """ - def __init__(self, parent, root): + def __init__(self, parent, root, session=None): super(NameWindow, self).__init__(parent=parent) self.setWindowFlags(self.windowFlags() | QtCore.Qt.FramelessWindowHint) self.result = None self.host = api.registered_host() - self.root = self.host.work_root() + self.root = root self.work_file = None - # Get work file name + if session is None: + # Fallback to active session + session = api.Session + + # Set work file data for template formatting self.data = { "project": io.find_one( - {"name": api.Session["AVALON_PROJECT"], "type": "project"} + {"name": session["AVALON_PROJECT"], "type": "project"} ), "asset": io.find_one( - {"name": api.Session["AVALON_ASSET"], "type": "asset"} + {"name": session["AVALON_ASSET"], "type": "asset"} ), "task": { - "name": api.Session["AVALON_TASK"].lower(), - "label": api.Session["AVALON_TASK"] + "name": session["AVALON_TASK"].lower(), + "label": session["AVALON_TASK"] }, "version": 1, "user": getpass.getuser(), @@ -207,10 +212,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 @@ -278,11 +291,14 @@ def __init__(self, *args): self.widgets[name].setStyleSheet("QLabel{ font-size: 12pt; }") def refresh(self): + self.set_session(api.Session) + + def set_session(self, session): self.context = { - "project": api.Session["AVALON_PROJECT"], - "asset": api.Session["AVALON_ASSET"], - "task": api.Session["AVALON_TASK"] + "project": session["AVALON_PROJECT"], + "asset": session["AVALON_ASSET"], + "task": session["AVALON_TASK"] } # Refresh labels @@ -303,6 +319,9 @@ def refresh(self): class TasksWidget(QtWidgets.QWidget): + + task_changed = QtCore.Signal() + def __init__(self): super(TasksWidget, self).__init__() self.setContentsMargins(0, 0, 0, 0) @@ -319,6 +338,10 @@ def __init__(self): # Hide the default tasks "count" as we don't need that data here. view.setColumnHidden(1, True) + selection = view.selectionModel() + #selection.selectionChanged.connect(self.selection_changed) + selection.currentChanged.connect(self.task_changed) + self.models = { "tasks": model } @@ -331,6 +354,10 @@ def __init__(self): def set_asset(self, asset_id): + 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. @@ -343,6 +370,9 @@ def set_asset(self, asset_id): if self._last_selected_task: self.select_task(self._last_selected_task) + # Force a task changed emit. + self.task_changed.emit() + def select_task(self, task): """Select a task by name. @@ -386,67 +416,36 @@ def get_current_task(self): class FilesWidget(QtWidgets.QWidget): """A widget displaying files that allows to save""" - # todo: separate out the files display from window - pass - - -class Window(QtWidgets.QMainWindow): - """Work Files Window""" - title = "Work Files" - def __init__(self, parent=None): - super(Window, self).__init__(parent) - self.setWindowTitle(self.title) - self.setWindowFlags(QtCore.Qt.Window | QtCore.Qt.WindowCloseButtonHint) + super(FilesWidget, self).__init__(parent=parent) # Setup + self._asset = None + self._task = None self.root = None self.host = api.registered_host() - pages = { - "home": QtWidgets.QWidget() - } - widgets = { - "pages": QtWidgets.QStackedWidget(), - "header": ContextBreadcrumb(), - "body": QtWidgets.QWidget(), - "assets": AssetWidget(silo_creatable=False), - "tasks": TasksWidget(), - "files": QtWidgets.QWidget(), - "fileList": QtWidgets.QListWidget(), - "fileDuplicate": QtWidgets.QPushButton("Duplicate"), - "fileOpen": QtWidgets.QPushButton("Open"), - "fileBrowse": QtWidgets.QPushButton("Browse"), - "fileCurrent": QtWidgets.QLabel(), - "fileSave": QtWidgets.QPushButton("Save As") + "list": QtWidgets.QListWidget(), + "duplicate": QtWidgets.QPushButton("Duplicate"), + "open": QtWidgets.QPushButton("Open"), + "browse": QtWidgets.QPushButton("Browse"), + #"currentFile": QtWidgets.QLabel(), + "save": QtWidgets.QPushButton("Save As") } - self.setCentralWidget(widgets["pages"]) - widgets["pages"].addWidget(pages["home"]) - - # Build homepage - layout = QtWidgets.QVBoxLayout(pages["home"]) - layout.addWidget(widgets["header"]) - layout.addWidget(widgets["body"]) - - # Build body - layout = QtWidgets.QHBoxLayout(widgets["body"]) - layout.addWidget(widgets["assets"]) - layout.addWidget(widgets["tasks"]) - layout.addWidget(widgets["files"]) - # Build buttons widget for files widget buttons = QtWidgets.QWidget() layout = QtWidgets.QHBoxLayout(buttons) layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(widgets["fileDuplicate"]) - layout.addWidget(widgets["fileOpen"]) - layout.addWidget(widgets["fileBrowse"]) + #layout.addWidget(widgets["duplicate"]) + layout.addWidget(widgets["open"]) + layout.addWidget(widgets["browse"]) # Build files widgets - layout = QtWidgets.QVBoxLayout(widgets["files"]) - layout.addWidget(widgets["fileList"]) + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(widgets["list"]) layout.addWidget(buttons) separator = QtWidgets.QFrame() @@ -454,115 +453,66 @@ def __init__(self, parent=None): separator.setFrameShadow(QtWidgets.QFrame.Plain) layout.addWidget(separator) - layout.addWidget(widgets["fileCurrent"]) - layout.addWidget(widgets["fileSave"]) + #layout.addWidget(widgets["currentFile"]) + layout.addWidget(widgets["save"]) - widgets["fileDuplicate"].pressed.connect(self.on_duplicate_pressed) - widgets["fileOpen"].pressed.connect(self.on_open_pressed) - widgets["fileList"].doubleClicked.connect(self.on_open_pressed) - widgets["tasks"].widgets["view"].doubleClicked.connect( - self.on_task_pressed - ) - widgets["fileBrowse"].pressed.connect(self.on_browse_pressed) - widgets["fileSave"].pressed.connect(self.on_save_as_pressed) - - # Force focus on the open button by default, required for Houdini. - widgets["fileOpen"].setFocus() - - # Connect signals - widgets["assets"].current_changed.connect(self.on_asset_changed) + widgets["list"].doubleClicked.connect(self.on_open_pressed) + widgets["duplicate"].pressed.connect(self.on_duplicate_pressed) + 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.refresh() - self.resize(750, 500) - - self.widgets["fileOpen"].setFocus() - - 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["data"].get("silo") - if self.widgets["assets"].get_current_silo() != silo: - self.widgets["assets"].set_silo(silo) + def set_asset_task(self, asset, task): + self._asset = asset + self._task = task - # Select the asset - self.widgets["assets"].select_assets([asset], expand=True) + def _get_session(self): + """Return a modified session for the current asset and task""" - if "task" in context: - self.widgets["tasks"].select_task(context["task"]) + session = api.Session.copy() + # todo: expose this in the API? + changes = pipeline.compute_session_changes(session, + asset=self._asset, + task=self._task) + session.update(changes) - def get_filename(self): - """Show save dialog to define filename for save or duplicate - - Returns: - str: The filename to create. + return session - """ - window = NameWindow(parent=self, - root=self.root) - window.exec_() + def _enter_session(self): + """Enter the asset and task session currently selected""" - return window.get_result() - - def refresh(self): - - self.root = self.host.work_root() - - # Refresh asset widget - self.widgets["assets"].refresh() - - # Refresh current scene label - filepath = self.host.current_file() - current = os.path.basename(filepath) if filepath else "" - self.widgets["fileCurrent"].setText("Current File: %s" % current) - - # Refresh breadcrumbs - self.widgets["header"].refresh() - - # Refresh files list - file_list = self.widgets["fileList"] - file_list.clear() - - modified = [] - extensions = set(self.host.file_extensions()) - - if os.path.exists(self.root): - for f in sorted(os.listdir(self.root)): - path = os.path.join(self.root, f) - if os.path.isdir(path): - continue + 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 - if extensions and os.path.splitext(f)[1] not in extensions: - continue + api.update_current_task(asset=self._asset, task=self._task) - file_list.addItem(f) - modified.append(os.path.getmtime(path)) - else: - log.warning("Work root does not exist: %s" % self.root) - - # Select last modified file - if file_list.count(): - item = file_list.item(modified.index(max(modified))) - item.setSelected(True) + def open_file(self, filepath): + host = self.host + if host.has_unsaved_changes(): + result = self.save_changes_prompt() - # Scroll list so item is visible - def callback(): - """Delayed callback to scroll to the item""" - self.widgets["fileList"].scrollToItem(item) + if result is None: + # Cancel operation + return False - QtCore.QTimer.singleShot(100, callback) + if result: + # Save current scene, continue to open file + host.save_file(host.current_file()) - self.widgets["fileDuplicate"].setEnabled(True) - else: - self.widgets["fileDuplicate"].setEnabled(False) + else: + # Don't save, continue to open file + pass - file_list.setMinimumWidth(file_list.sizeHintForColumn(0) + 30) + self._enter_session() + return host.open_file(filepath) def save_changes_prompt(self): messagebox = QtWidgets.QMessageBox(parent=self) @@ -585,24 +535,21 @@ def save_changes_prompt(self): else: return None - def open(self, filepath): - host = self.host - if host.has_unsaved_changes(): - result = self.save_changes_prompt() + def get_filename(self): + """Show save dialog to define filename for save or duplicate - if result is None: - # Cancel operation - return False + Returns: + str: The filename to create. - if result: - # Save current scene, continue to open file - host.save_file(host.current_file()) + """ + session = self._get_session() - else: - # Don't save, continue to open file - pass + window = NameWindow(parent=self, + root=self.root, + session=session) + window.exec_() - return host.open_file(filepath) + return window.get_result() def on_duplicate_pressed(self): work_file = self.get_filename() @@ -611,7 +558,7 @@ def on_duplicate_pressed(self): return src = os.path.join( - self.root, self.widgets["fileList"].selectedItems()[0].text() + self.root, self.widgets["list"].selectedItems()[0].text() ) dst = os.path.join( self.root, work_file @@ -622,16 +569,13 @@ def on_duplicate_pressed(self): def on_open_pressed(self): - selection = self.widgets["fileList"].selectedItems() + selection = self.widgets["list"].selectedItems() if not selection: 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(work_file) def on_browse_pressed(self): @@ -644,12 +588,9 @@ 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_filename() @@ -658,18 +599,185 @@ def on_save_as_pressed(self): 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.refresh() - self.close() + def refresh(self): + """Refresh listed files for current selection in the interface""" + + # Refresh current scene label + #filepath = self.host.current_file() + #current = os.path.basename(filepath) if filepath else "" + #self.widgets["currentFile"].setText("Current File: %s" % current) + + # Clear the list + file_list = self.widgets["list"] + file_list.clear() + + if not self._asset: + # No asset selected + return + + if not self._task: + # No task selected + return + + # 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. + # todo: refactor to use pipeline.compute_session_changes() + session = get_asset_task_session(self._asset, self._task) + self.root = self.host.work_root(session) + + modified = [] + extensions = set(self.host.file_extensions()) + + if os.path.exists(self.root): + 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 + + file_list.addItem(f) + modified.append(os.path.getmtime(path)) + else: + log.warning("Work root does not exist: %s" % self.root) + + # Select last modified file + if file_list.count(): + item = file_list.item(modified.index(max(modified))) + item.setSelected(True) + + # Scroll list so item is visible + def callback(): + """Delayed callback to scroll to the item""" + self.widgets["list"].scrollToItem(item) + + QtCore.QTimer.singleShot(100, callback) + + self.widgets["duplicate"].setEnabled(True) + else: + self.widgets["duplicate"].setEnabled(False) + + file_list.setMinimumWidth(file_list.sizeHintForColumn(0) + 30) + + +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(), + "header": ContextBreadcrumb(), + "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["header"]) + 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) + + # Connect signals + widgets["tasks"].widgets["view"].doubleClicked.connect( + self.on_task_pressed + ) + widgets["assets"].current_changed.connect(self.on_asset_changed) + widgets["tasks"].task_changed.connect(self.on_task_changed) + + self.widgets = widgets + self.refresh() + + # Force focus on the open button by default, required for Houdini. + self.widgets["files"].widgets["open"].setFocus() + + self.resize(900, 600) + + def 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["data"].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() + + # Refresh breadcrumbs + #self.widgets["header"].set_session(session) + + 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_pressed(self): - asset_id = self.widgets["assets"].get_active_asset() - if not asset_id: + asset = self.widgets["assets"].get_active_asset_document() + if not asset: log.warning("No asset selected.") return @@ -678,19 +786,25 @@ def on_task_pressed(self): log.warning("No task selected.") return - # Get the asset name from asset id. - asset = io.find_one({"_id": io.ObjectId(asset_id), "type": "asset"}) - if not asset: - log.error("Invalid asset id: %s" % asset_id) - return - asset_name = asset["name"] api.update_current_task(task=task_name, asset=asset_name) self.refresh() + 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)])) -def show(root=None, debug=False, parent=None): + 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() @@ -727,6 +841,14 @@ def show(root=None, debug=False, parent=None): window.setStyleSheet(style.load_stylesheet()) 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()) From af1917fdbe4c64fc6912004053f1c4d5f4155420 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 17 Dec 2019 21:22:03 +0100 Subject: [PATCH 10/32] Remove double Window initialization --- avalon/tools/workfiles/app.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/avalon/tools/workfiles/app.py b/avalon/tools/workfiles/app.py index 91ee58084..1788fca85 100644 --- a/avalon/tools/workfiles/app.py +++ b/avalon/tools/workfiles/app.py @@ -837,8 +837,6 @@ def show(root=None, debug=False, parent=None, use_context=True): api.Session["AVALON_TASK"] = "Testing" with tools_lib.application(): - window = Window() - window.setStyleSheet(style.load_stylesheet()) window = Window(parent=parent) window.refresh() From bb0597406ff6c29499862f27c9ad4f4182f700dd Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 17 Dec 2019 21:29:10 +0100 Subject: [PATCH 11/32] Fix missed refactor to self._get_session --- avalon/tools/workfiles/app.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/avalon/tools/workfiles/app.py b/avalon/tools/workfiles/app.py index 1788fca85..afde5be73 100644 --- a/avalon/tools/workfiles/app.py +++ b/avalon/tools/workfiles/app.py @@ -627,8 +627,7 @@ def refresh(self): # 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. - # todo: refactor to use pipeline.compute_session_changes() - session = get_asset_task_session(self._asset, self._task) + session = self._get_session() self.root = self.host.work_root(session) modified = [] From 051cb991f683dc35c08f3fd9522e7c3727884fac Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 17 Dec 2019 21:44:15 +0100 Subject: [PATCH 12/32] Correctly only return changes that actually changed --- avalon/pipeline.py | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/avalon/pipeline.py b/avalon/pipeline.py index 965eae6d6..c2964aaa6 100644 --- a/avalon/pipeline.py +++ b/avalon/pipeline.py @@ -953,24 +953,32 @@ def compute_session_changes(session, task=None, asset=None, app=None): if not any([task, asset, app]): return changes - if task: - changes["AVALON_TASK"] = task - - if app: - changes["AVALON_APP"] = app - - # Update silo and hierarchy when asset changed + # Get asset document and asset + asset_document = None if asset: if isinstance(asset, dict): - # Assume database document + # 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" - changes["AVALON_ASSET"] = asset + # Detect any changes compared session + mapping = { + "AVALON_ASSET": asset, + "AVALON_TASK": task, + "AVALON_APP": app, + } + 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 changes["AVALON_SILO"] = asset_document["silo"] From 238809cae407af669c52dbd725bc8870d5d2b638 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 17 Dec 2019 21:46:34 +0100 Subject: [PATCH 13/32] Only update current task on double click if it's not current context --- avalon/tools/workfiles/app.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/avalon/tools/workfiles/app.py b/avalon/tools/workfiles/app.py index afde5be73..9e980c5e8 100644 --- a/avalon/tools/workfiles/app.py +++ b/avalon/tools/workfiles/app.py @@ -785,8 +785,14 @@ def on_task_pressed(self): log.warning("No task selected.") return - asset_name = asset["name"] - api.update_current_task(task=task_name, asset=asset_name) + changes = pipeline.compute_session_changes(session=api.Session, + asset=asset, + task=task_name) + if not changes: + # No need to update since we are already there. + return + + api.update_current_task(task=task_name, asset=asset) self.refresh() From c3d0b7d4d6508213b03954cbc179153a760d70db Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 17 Dec 2019 22:06:59 +0100 Subject: [PATCH 14/32] Fix save current file prompt not showing up because of parenting --- avalon/tools/workfiles/app.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/avalon/tools/workfiles/app.py b/avalon/tools/workfiles/app.py index 9e980c5e8..7eb649fbd 100644 --- a/avalon/tools/workfiles/app.py +++ b/avalon/tools/workfiles/app.py @@ -425,6 +425,10 @@ def __init__(self, parent=None): self.root = None self.host = api.registered_host() + # Avoid crash in Blender and store the message box + # (setting parent doesn't work as it hides the message box) + self._messagebox = None + widgets = { "list": QtWidgets.QListWidget(), "duplicate": QtWidgets.QPushButton("Duplicate"), @@ -515,7 +519,9 @@ def open_file(self, filepath): return host.open_file(filepath) def save_changes_prompt(self): - messagebox = QtWidgets.QMessageBox(parent=self) + self._messagebox = QtWidgets.QMessageBox() + messagebox = self._messagebox + messagebox.setWindowFlags(QtCore.Qt.FramelessWindowHint) messagebox.setIcon(messagebox.Warning) messagebox.setWindowTitle("Unsaved Changes!") @@ -526,6 +532,7 @@ def save_changes_prompt(self): messagebox.setStandardButtons( messagebox.Yes | messagebox.No | messagebox.Cancel ) + result = messagebox.exec_() if result == messagebox.Yes: From e60e30694f6e4cc90913e5392548ac0db08cd498 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 17 Dec 2019 22:13:49 +0100 Subject: [PATCH 15/32] Log error when user wants to save current file without name on scene open. This captures an edge case where if the user had never saved before but has scene modification, then tries to open a Work File and click "Yes" on Save Changes where it actually couldn't due to the file not having any saved name yet. --- avalon/tools/workfiles/app.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/avalon/tools/workfiles/app.py b/avalon/tools/workfiles/app.py index 7eb649fbd..294deaf6c 100644 --- a/avalon/tools/workfiles/app.py +++ b/avalon/tools/workfiles/app.py @@ -508,8 +508,19 @@ def open_file(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 From d7130433f2afd2c7f32643d35f25267cf5e551ed Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 22 Dec 2019 13:50:00 +0100 Subject: [PATCH 16/32] Work files: add date modified, use model->view, duplicate to context menu, add filename filter - This moves the PrettyTimeDelegate to tools/delegates since it is now used by both Loader and Work Files tool - This also allows the delegate to operate on a float as opposed to the preformatted time string --- avalon/tools/delegates.py | 110 ++++++++++++++++++ avalon/tools/loader/delegates.py | 102 ----------------- avalon/tools/loader/widgets.py | 2 +- avalon/tools/workfiles/app.py | 185 ++++++++++++++++++++----------- avalon/tools/workfiles/model.py | 127 +++++++++++++++++++++ 5 files changed, 356 insertions(+), 170 deletions(-) delete mode 100644 avalon/tools/loader/delegates.py create mode 100644 avalon/tools/workfiles/model.py 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/workfiles/app.py b/avalon/tools/workfiles/app.py index 294deaf6c..456932785 100644 --- a/avalon/tools/workfiles/app.py +++ b/avalon/tools/workfiles/app.py @@ -13,6 +13,9 @@ from .. import lib as tools_lib from ..widgets import AssetWidget from ..models import TasksModel +from ..delegates import PrettyTimeDelegate + +from .model import FilesModel log = logging.getLogger(__name__) @@ -411,7 +414,13 @@ def get_current_task(self): view = self.widgets["view"] index = view.currentIndex() index = index.sibling(index.row(), 0) # ensure column zero for name - return index.data(QtCore.Qt.DisplayRole) + + selection = view.selectionModel() + if selection.isSelected(index): + # Ignore when the current task is not selected as the "No task" + # placeholder might be the current index even though it's + # disallowed to be selected. So we only return if it is selected. + return index.data(QtCore.Qt.DisplayRole) class FilesWidget(QtWidgets.QWidget): @@ -425,58 +434,94 @@ def __init__(self, parent=None): self.root = None self.host = api.registered_host() + # Whether to automatically select the latest modified + # file on a refresh of the files model. + self.auto_select_latest_modified = True + # Avoid crash in Blender and store the message box # (setting parent doesn't work as it hides the message box) self._messagebox = None widgets = { - "list": QtWidgets.QListWidget(), + "filter": QtWidgets.QLineEdit(), + "list": QtWidgets.QTreeView(), "duplicate": QtWidgets.QPushButton("Duplicate"), "open": QtWidgets.QPushButton("Open"), "browse": QtWidgets.QPushButton("Browse"), - #"currentFile": QtWidgets.QLabel(), "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"]) + + # 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..") + # Build buttons widget for files widget buttons = QtWidgets.QWidget() layout = QtWidgets.QHBoxLayout(buttons) layout.setContentsMargins(0, 0, 0, 0) - #layout.addWidget(widgets["duplicate"]) layout.addWidget(widgets["open"]) layout.addWidget(widgets["browse"]) + layout.addWidget(widgets["save"]) # Build files widgets layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(widgets["filter"]) layout.addWidget(widgets["list"]) layout.addWidget(buttons) - separator = QtWidgets.QFrame() - separator.setFrameShape(QtWidgets.QFrame.HLine) - separator.setFrameShadow(QtWidgets.QFrame.Plain) - layout.addWidget(separator) - - #layout.addWidget(widgets["currentFile"]) - layout.addWidget(widgets["save"]) - widgets["list"].doubleClicked.connect(self.on_open_pressed) + widgets["list"].customContextMenuRequested.connect( + self.on_context_menu + ) widgets["duplicate"].pressed.connect(self.on_duplicate_pressed) 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) + self.model.set_root(self.root) + else: + self.model.set_root(None) + def _get_session(self): """Return a modified session for the current asset and task""" session = api.Session.copy() - # todo: expose this in the API? changes = pipeline.compute_session_changes(session, asset=self._asset, task=self._task) @@ -570,14 +615,13 @@ def get_filename(self): return window.get_result() def on_duplicate_pressed(self): + work_file = self.get_filename() if not work_file: return - src = os.path.join( - self.root, self.widgets["list"].selectedItems()[0].text() - ) + src = self._get_selected_filepath() dst = os.path.join( self.root, work_file ) @@ -585,15 +629,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.widgets["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()) - return self.open_file(work_file) + return self.open_file(path) def on_browse_pressed(self): @@ -624,64 +678,56 @@ def on_save_as_pressed(self): def refresh(self): """Refresh listed files for current selection in the interface""" + self.model.refresh() - # Refresh current scene label - #filepath = self.host.current_file() - #current = os.path.basename(filepath) if filepath else "" - #self.widgets["currentFile"].setText("Current File: %s" % current) - - # Clear the list - file_list = self.widgets["list"] - file_list.clear() + if self.auto_select_latest_modified: + tools_lib.schedule(self._select_last_modified_file, + 100) - if not self._asset: - # No asset selected - return + def on_context_menu(self, point): - if not self._task: - # No task selected + view = self.widgets["list"] + index = view.indexAt(point) + if not index.isValid(): return - # 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. - session = self._get_session() - self.root = self.host.work_root(session) - - modified = [] - extensions = set(self.host.file_extensions()) + menu = QtWidgets.QMenu(self) - if os.path.exists(self.root): - 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 + # 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) - file_list.addItem(f) - modified.append(os.path.getmtime(path)) - else: - log.warning("Work root does not exist: %s" % self.root) + # Show the context action menu + global_point = view.mapToGlobal(point) + action = menu.exec_(global_point) + if not action: + return - # Select last modified file - if file_list.count(): - item = file_list.item(modified.index(max(modified))) - item.setSelected(True) + def _select_last_modified_file(self): + """Utility function to select the file with latest date modified""" - # Scroll list so item is visible - def callback(): - """Delayed callback to scroll to the item""" - self.widgets["list"].scrollToItem(item) + role = self.model.DateModifiedRole + view = self.widgets["list"] + model = view.model() - QtCore.QTimer.singleShot(100, callback) + highest_index = None + highest = 0 + for row in range(model.rowCount()): + index = model.index(row, 0, parent=QtCore.QModelIndex()) + if not index.isValid(): + continue - self.widgets["duplicate"].setEnabled(True) - else: - self.widgets["duplicate"].setEnabled(False) + modified = index.data(role) + if modified > highest: + highest_index = index + highest = modified - file_list.setMinimumWidth(file_list.sizeHintForColumn(0) + 30) + if highest_index: + view.setCurrentIndex(highest_index) class Window(QtWidgets.QMainWindow): @@ -725,11 +771,16 @@ def __init__(self, parent=None): 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["tasks"].widgets["view"].doubleClicked.connect( self.on_task_pressed ) 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 @@ -755,7 +806,7 @@ def set_context(self, context): "type": "asset"}) # Set silo - silo = asset_document["data"].get("silo") + silo = asset_document.get("silo") if self.widgets["assets"].get_current_silo() != silo: self.widgets["assets"].set_silo(silo) diff --git a/avalon/tools/workfiles/model.py b/avalon/tools/workfiles/model.py new file mode 100644 index 000000000..a3dbdc23e --- /dev/null +++ b/avalon/tools/workfiles/model.py @@ -0,0 +1,127 @@ +import os +import logging +from datetime import datetime + +from ... import io, style +from ...vendor.Qt import QtCore +from ...vendor import qtawesome + +from ..models import TreeModel, Item +from .. import lib + +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 + + 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): + log.error("Work root does not exist: %s" % root) + 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 + if index.column() == 0: + return self._icons["file"] + 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"] + + 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) + + def flags(self, index): + flags = QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + + # Make the version column editable + if index.column() == 2: # version column + flags |= QtCore.Qt.ItemIsEditable + + return flags From 9bc0164e5669882a73dab7ca57560235cea348b4 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 22 Dec 2019 13:57:24 +0100 Subject: [PATCH 17/32] Remove redundant flags() method for FilesModel --- avalon/tools/workfiles/model.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/avalon/tools/workfiles/model.py b/avalon/tools/workfiles/model.py index a3dbdc23e..ed5ac2e51 100644 --- a/avalon/tools/workfiles/model.py +++ b/avalon/tools/workfiles/model.py @@ -116,12 +116,3 @@ def headerData(self, section, orientation, role): return "Date modified" return super(FilesModel, self).headerData(section, orientation, role) - - def flags(self, index): - flags = QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable - - # Make the version column editable - if index.column() == 2: # version column - flags |= QtCore.Qt.ItemIsEditable - - return flags From 83617b63544ccb0336a8afc2726fd2e8dfacb69f Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 23 Dec 2019 11:04:56 +0100 Subject: [PATCH 18/32] Fix undefined work_dir and scene_dir variables --- avalon/nuke/workio.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/avalon/nuke/workio.py b/avalon/nuke/workio.py index 49efc1407..65b86bf01 100644 --- a/avalon/nuke/workio.py +++ b/avalon/nuke/workio.py @@ -44,6 +44,8 @@ def current_file(): 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: From 2f4aa80808010be8f8dfc10bdac54c7a05ad0a85 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 23 Dec 2019 11:13:21 +0100 Subject: [PATCH 19/32] Add comment why we are parsing Maya workspace.mel manually --- avalon/maya/workio.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/avalon/maya/workio.py b/avalon/maya/workio.py index d591c7b27..ad491a127 100644 --- a/avalon/maya/workio.py +++ b/avalon/maya/workio.py @@ -35,6 +35,10 @@ def work_root(session): 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" ' From 3d7c65e1c3aecc92fa4a0a08a174af2c940df8de Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 23 Dec 2019 11:14:33 +0100 Subject: [PATCH 20/32] Code cosmetics (PEP8) --- avalon/pipeline.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/avalon/pipeline.py b/avalon/pipeline.py index c2964aaa6..132df6098 100644 --- a/avalon/pipeline.py +++ b/avalon/pipeline.py @@ -972,8 +972,8 @@ def compute_session_changes(session, task=None, asset=None, app=None): "AVALON_TASK": task, "AVALON_APP": app, } - changes = {key: value for key, value in mapping.items() if value - and value != session.get(key)} + changes = {key: value for key, value in mapping.items() + if value and value != session.get(key)} if not changes: return changes From 0aa7781875aab35efa2ff97ea2f9ec3ce2ccaa49 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 6 Jan 2020 16:09:45 +0100 Subject: [PATCH 21/32] Implement "Create Work Area" for Work Files + Clean up code --- avalon/tools/workfiles/app.py | 145 +++++++++++++++++----------------- 1 file changed, 74 insertions(+), 71 deletions(-) diff --git a/avalon/tools/workfiles/app.py b/avalon/tools/workfiles/app.py index 456932785..57c9b27fe 100644 --- a/avalon/tools/workfiles/app.py +++ b/avalon/tools/workfiles/app.py @@ -263,65 +263,8 @@ def refresh(self): ok.setEnabled(True) -class ContextBreadcrumb(QtWidgets.QWidget): - """Horizontal widget showing current avalon project, asset and task.""" - - def __init__(self, *args): - QtWidgets.QWidget.__init__(self, *args) - - self.context = {} - self.widgets = { - "projectIcon": QtWidgets.QLabel(), - "assetIcon": QtWidgets.QLabel(), - "project": QtWidgets.QLabel(), - "asset": QtWidgets.QLabel(), - "task": QtWidgets.QLabel(), - "taskIcon": QtWidgets.QLabel(), - } - - layout = QtWidgets.QHBoxLayout(self) - layout.addWidget(self.widgets["projectIcon"]) - layout.addWidget(self.widgets["project"]) - layout.addWidget(QtWidgets.QLabel(u"\u25B6")) - layout.addWidget(self.widgets["assetIcon"]) - layout.addWidget(self.widgets["asset"]) - layout.addWidget(QtWidgets.QLabel(u"\u25B6")) - layout.addWidget(self.widgets["taskIcon"]) - layout.addWidget(self.widgets["task"]) - layout.addStretch() - - for name in ["project", "asset", "task"]: - self.widgets[name].setStyleSheet("QLabel{ font-size: 12pt; }") - - def refresh(self): - self.set_session(api.Session) - - def set_session(self, session): - - self.context = { - "project": session["AVALON_PROJECT"], - "asset": session["AVALON_ASSET"], - "task": session["AVALON_TASK"] - } - - # Refresh labels - for key, value in self.context.items(): - self.widgets[key].setText(value) - - # todo: match icons from database when supplied - icons = { - "projectIcon": "fa.map", - "assetIcon": "fa.plus-square", - "taskIcon": "fa.male" - } - - for key, value in icons.items(): - icon = qtawesome.icon(value, - color=style.colors.default).pixmap(18, 18) - self.widgets[key].setPixmap(icon) - - class TasksWidget(QtWidgets.QWidget): + """Widget showing active Tasks""" task_changed = QtCore.Signal() @@ -342,7 +285,6 @@ def __init__(self): view.setColumnHidden(1, True) selection = view.selectionModel() - #selection.selectionChanged.connect(self.selection_changed) selection.currentChanged.connect(self.task_changed) self.models = { @@ -423,8 +365,8 @@ def get_current_task(self): return index.data(QtCore.Qt.DisplayRole) -class FilesWidget(QtWidgets.QWidget): - """A widget displaying files that allows to save""" +class FilesWidget(QtWidgets.QStackedWidget): + """A widget displaying files that allows to save and open files.""" def __init__(self, parent=None): super(FilesWidget, self).__init__(parent=parent) @@ -442,12 +384,17 @@ def __init__(self, parent=None): # (setting parent doesn't work as it hides the message box) self._messagebox = None + pages = { + "home": QtWidgets.QWidget(), + "init": QtWidgets.QWidget() + } + widgets = { "filter": QtWidgets.QLineEdit(), "list": QtWidgets.QTreeView(), - "duplicate": QtWidgets.QPushButton("Duplicate"), "open": QtWidgets.QPushButton("Open"), "browse": QtWidgets.QPushButton("Browse"), + "create": QtWidgets.QPushButton("Create Work Area"), "save": QtWidgets.QPushButton("Save As") } @@ -477,6 +424,7 @@ def __init__(self, parent=None): 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) @@ -485,22 +433,36 @@ def __init__(self, parent=None): layout.addWidget(widgets["browse"]) layout.addWidget(widgets["save"]) - # Build files widgets - layout = QtWidgets.QVBoxLayout(self) + # Build files widgets for home page + layout = QtWidgets.QVBoxLayout(pages["home"]) layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(widgets["filter"]) layout.addWidget(widgets["list"]) layout.addWidget(buttons) + # Initialize Work Area Page + layout = QtWidgets.QVBoxLayout(pages["init"]) + layout.addStretch(1) + label = QtWidgets.QLabel("Work area does not exist.") + label.setAlignment(QtCore.Qt.AlignCenter) + layout.addWidget(label) + layout.addWidget(widgets["create"]) + layout.addStretch(10) + + # Add pages + self.addWidget(pages["home"]) + self.addWidget(pages["init"]) + widgets["list"].doubleClicked.connect(self.on_open_pressed) widgets["list"].customContextMenuRequested.connect( self.on_context_menu ) - widgets["duplicate"].pressed.connect(self.on_duplicate_pressed) 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) + widgets["create"].pressed.connect(self.on_create_pressed) + self.pages = pages self.widgets = widgets self.delegates = delegates @@ -514,7 +476,12 @@ def set_asset_task(self, asset, task): if self._asset and self._task: session = self._get_session() self.root = self.host.work_root(session) - self.model.set_root(self.root) + + if not os.path.exists(self.root): + self.setCurrentWidget(self.pages["init"]) + else: + self.setCurrentWidget(self.pages["home"]) + self.model.set_root(self.root) else: self.model.set_root(None) @@ -589,6 +556,10 @@ def save_changes_prompt(self): 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: @@ -676,6 +647,43 @@ def on_save_as_pressed(self): self.host.save_file(file_path) self.refresh() + def on_create_pressed(self): + """On "Create Work Area" clicked. + + 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() @@ -745,7 +753,6 @@ def __init__(self, parent=None): widgets = { "pages": QtWidgets.QStackedWidget(), - "header": ContextBreadcrumb(), "body": QtWidgets.QWidget(), "assets": AssetWidget(silo_creatable=False), "tasks": TasksWidget(), @@ -757,7 +764,6 @@ def __init__(self, parent=None): # Build home layout = QtWidgets.QVBoxLayout(pages["home"]) - #layout.addWidget(widgets["header"]) layout.addWidget(widgets["body"]) # Build home - body @@ -824,9 +830,6 @@ def refresh(self): # Refresh asset widget self.widgets["assets"].refresh() - # Refresh breadcrumbs - #self.widgets["header"].set_session(session) - self._on_task_changed() def _on_asset_changed(self): From 0617307add6ab7ea3ecbe5eb850083ce481cd36e Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 6 Jan 2020 16:10:27 +0100 Subject: [PATCH 22/32] Remove on_task_pressed functionality --- avalon/tools/workfiles/app.py | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/avalon/tools/workfiles/app.py b/avalon/tools/workfiles/app.py index 57c9b27fe..b6995f3d8 100644 --- a/avalon/tools/workfiles/app.py +++ b/avalon/tools/workfiles/app.py @@ -782,9 +782,6 @@ def __init__(self, parent=None): widgets["tasks"].setContentsMargins(0, 32, 0, 0) # Connect signals - widgets["tasks"].widgets["view"].doubleClicked.connect( - self.on_task_pressed - ) 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) @@ -845,29 +842,6 @@ def _on_asset_changed(self): self.widgets["tasks"].set_asset(asset) - def on_task_pressed(self): - - asset = self.widgets["assets"].get_active_asset_document() - if not asset: - log.warning("No asset selected.") - return - - task_name = self.widgets["tasks"].get_current_task() - if not task_name: - log.warning("No task selected.") - return - - changes = pipeline.compute_session_changes(session=api.Session, - asset=asset, - task=task_name) - if not changes: - # No need to update since we are already there. - return - - api.update_current_task(task=task_name, asset=asset) - - self.refresh() - def _on_task_changed(self): asset = self.widgets["assets"].get_active_asset_document() From b7b95fd2eceb40eb9449bb08af9b0641f85f774e Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 6 Jan 2020 16:14:53 +0100 Subject: [PATCH 23/32] Cleanup code --- avalon/maya/workio.py | 2 +- avalon/tools/workfiles/app.py | 1 - avalon/tools/workfiles/model.py | 4 +--- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/avalon/maya/workio.py b/avalon/maya/workio.py index ad491a127..11c67fa28 100644 --- a/avalon/maya/workio.py +++ b/avalon/maya/workio.py @@ -57,4 +57,4 @@ def work_root(session): if scene_dir: return os.path.join(work_dir, scene_dir) else: - return work_dir \ No newline at end of file + return work_dir diff --git a/avalon/tools/workfiles/app.py b/avalon/tools/workfiles/app.py index b6995f3d8..ae68c1cd6 100644 --- a/avalon/tools/workfiles/app.py +++ b/avalon/tools/workfiles/app.py @@ -7,7 +7,6 @@ import platform from ...vendor.Qt import QtWidgets, QtCore -from ...vendor import qtawesome from ... import style, io, api, pipeline from .. import lib as tools_lib diff --git a/avalon/tools/workfiles/model.py b/avalon/tools/workfiles/model.py index ed5ac2e51..d91f6b84b 100644 --- a/avalon/tools/workfiles/model.py +++ b/avalon/tools/workfiles/model.py @@ -1,13 +1,11 @@ import os import logging -from datetime import datetime -from ... import io, style +from ... import style from ...vendor.Qt import QtCore from ...vendor import qtawesome from ..models import TreeModel, Item -from .. import lib log = logging.getLogger(__name__) From 050f442f81652ec949cdbe4e6320c5647e768b66 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 6 Jan 2020 16:35:17 +0100 Subject: [PATCH 24/32] Change code style (try to shush the Hound) --- avalon/maya/workio.py | 6 ++++-- avalon/tools/workfiles/app.py | 10 ++++------ avalon/tools/workfiles/model.py | 3 +-- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/avalon/maya/workio.py b/avalon/maya/workio.py index 11c67fa28..9c036a35f 100644 --- a/avalon/maya/workio.py +++ b/avalon/maya/workio.py @@ -47,8 +47,10 @@ def work_root(session): with open_file(workspace_mel, "r") as f: for line in f: if line.strip().startswith(scene_rule): - remainder = line[len(scene_rule):] # == "rule"; - scene_dir = remainder.split('"')[1] # == 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. diff --git a/avalon/tools/workfiles/app.py b/avalon/tools/workfiles/app.py index ae68c1cd6..9b08bfced 100644 --- a/avalon/tools/workfiles/app.py +++ b/avalon/tools/workfiles/app.py @@ -45,12 +45,10 @@ def __init__(self, parent, root, session=None): # 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"} - ), + "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"] diff --git a/avalon/tools/workfiles/model.py b/avalon/tools/workfiles/model.py index d91f6b84b..3d9f4dcbb 100644 --- a/avalon/tools/workfiles/model.py +++ b/avalon/tools/workfiles/model.py @@ -12,8 +12,7 @@ class FilesModel(TreeModel): """Model listing files with specified extensions in a root folder""" - Columns = ["filename", - "date"] + Columns = ["filename", "date"] FileNameRole = QtCore.Qt.UserRole + 2 DateModifiedRole = QtCore.Qt.UserRole + 3 From ff309791e330c8b54745a4b73294665d556b6db1 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 6 Jan 2020 17:33:19 +0100 Subject: [PATCH 25/32] Allow Work Directory creation in Work Files tool on Save As --- avalon/tools/workfiles/app.py | 56 ++++++++++++++------------------- avalon/tools/workfiles/model.py | 19 +++++++++-- 2 files changed, 41 insertions(+), 34 deletions(-) diff --git a/avalon/tools/workfiles/app.py b/avalon/tools/workfiles/app.py index 9b08bfced..7044de599 100644 --- a/avalon/tools/workfiles/app.py +++ b/avalon/tools/workfiles/app.py @@ -196,7 +196,7 @@ def refresh(self): 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() @@ -362,7 +362,7 @@ def get_current_task(self): return index.data(QtCore.Qt.DisplayRole) -class FilesWidget(QtWidgets.QStackedWidget): +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) @@ -381,17 +381,11 @@ def __init__(self, parent=None): # (setting parent doesn't work as it hides the message box) self._messagebox = None - pages = { - "home": QtWidgets.QWidget(), - "init": QtWidgets.QWidget() - } - widgets = { "filter": QtWidgets.QLineEdit(), "list": QtWidgets.QTreeView(), "open": QtWidgets.QPushButton("Open"), "browse": QtWidgets.QPushButton("Browse"), - "create": QtWidgets.QPushButton("Create Work Area"), "save": QtWidgets.QPushButton("Save As") } @@ -413,6 +407,7 @@ def __init__(self, parent=None): 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. @@ -431,25 +426,12 @@ def __init__(self, parent=None): layout.addWidget(widgets["save"]) # Build files widgets for home page - layout = QtWidgets.QVBoxLayout(pages["home"]) + layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(widgets["filter"]) layout.addWidget(widgets["list"]) layout.addWidget(buttons) - # Initialize Work Area Page - layout = QtWidgets.QVBoxLayout(pages["init"]) - layout.addStretch(1) - label = QtWidgets.QLabel("Work area does not exist.") - label.setAlignment(QtCore.Qt.AlignCenter) - layout.addWidget(label) - layout.addWidget(widgets["create"]) - layout.addStretch(10) - - # Add pages - self.addWidget(pages["home"]) - self.addWidget(pages["init"]) - widgets["list"].doubleClicked.connect(self.on_open_pressed) widgets["list"].customContextMenuRequested.connect( self.on_context_menu @@ -457,9 +439,7 @@ def __init__(self, parent=None): 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) - widgets["create"].pressed.connect(self.on_create_pressed) - self.pages = pages self.widgets = widgets self.delegates = delegates @@ -474,11 +454,10 @@ def set_asset_task(self, asset, task): session = self._get_session() self.root = self.host.work_root(session) - if not os.path.exists(self.root): - self.setCurrentWidget(self.pages["init"]) - else: - self.setCurrentWidget(self.pages["home"]) - self.model.set_root(self.root) + exists = os.path.exists(self.root) + self.widgets["browse"].setEnabled(exists) + self.widgets["open"].setEnabled(exists) + self.model.set_root(self.root) else: self.model.set_root(None) @@ -633,19 +612,32 @@ def on_browse_pressed(self): self.open_file(work_file) def on_save_as_pressed(self): - work_file = self.get_filename() + work_file = self.get_filename() if not work_file: return + # Initialize work directory if it has not been initialized before + if not os.path.exists(self.root): + log.debug("Initializing Work Directory: %s", self.root) + self.initialize_work_directory() + if not os.path.exists(self.root): + # Failed to initialize Work Directory + log.error("Failed to initialize Work Directory: " + "%s", self.root) + return + file_path = os.path.join(self.root, work_file) self._enter_session() # Make sure we are in the right session self.host.save_file(file_path) + self.set_asset_task(self._asset, self._task) self.refresh() - def on_create_pressed(self): - """On "Create Work Area" clicked. + 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 diff --git a/avalon/tools/workfiles/model.py b/avalon/tools/workfiles/model.py index 3d9f4dcbb..a98884c7b 100644 --- a/avalon/tools/workfiles/model.py +++ b/avalon/tools/workfiles/model.py @@ -55,7 +55,18 @@ def refresh(self): return if not os.path.exists(root): - log.error("Work root does not exist: %s" % 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 @@ -88,8 +99,12 @@ def data(self, index, role): if role == QtCore.Qt.DecorationRole: # Add icon to filename column + item = index.internalPointer() if index.column() == 0: - return self._icons["file"] + if item["filepath"]: + return self._icons["file"] + else: + return item.get("icon", None) if role == self.FileNameRole: item = index.internalPointer() return item["filename"] From acf588608738c28948cfdfc8efd662225b79537e Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 11 Feb 2020 10:44:02 +0100 Subject: [PATCH 26/32] tasks model show `Tasks` instead of `name` --- avalon/tools/models.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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) From ee1ae651ac7cc0ea399ce8732d298c303ecc1318 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 11 Feb 2020 10:44:30 +0100 Subject: [PATCH 27/32] Do not show context menu if Item is not enabled --- avalon/tools/workfiles/app.py | 4 ++++ avalon/tools/workfiles/model.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/avalon/tools/workfiles/app.py b/avalon/tools/workfiles/app.py index 7044de599..aea56d727 100644 --- a/avalon/tools/workfiles/app.py +++ b/avalon/tools/workfiles/app.py @@ -688,6 +688,10 @@ def on_context_menu(self, point): if not index.isValid(): return + is_enabled = index.data(FilesModel.IsEnabled) + if not is_enabled: + return + menu = QtWidgets.QMenu(self) # Duplicate diff --git a/avalon/tools/workfiles/model.py b/avalon/tools/workfiles/model.py index a98884c7b..484297bc0 100644 --- a/avalon/tools/workfiles/model.py +++ b/avalon/tools/workfiles/model.py @@ -17,6 +17,7 @@ class FilesModel(TreeModel): 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) @@ -114,6 +115,9 @@ def data(self, index, role): 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) From be07276e842ec6b3232ca636009595fc1ce36691 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 11 Feb 2020 11:04:12 +0100 Subject: [PATCH 28/32] removed contextmanager from hosts --- avalon/maya/pipeline.py | 22 +-- avalon/nuke/pipeline.py | 13 +- avalon/tools/contextmanager/__init__.py | 3 - avalon/tools/contextmanager/app.py | 229 ------------------------ res/houdini/MainMenuCommon.XML | 17 -- 5 files changed, 4 insertions(+), 280 deletions(-) delete mode 100644 avalon/tools/contextmanager/__init__.py delete mode 100644 avalon/tools/contextmanager/app.py diff --git a/avalon/maya/pipeline.py b/avalon/maya/pipeline.py index 55c4bb490..48d4ce2bb 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 @@ -142,21 +141,6 @@ def deferred(): tearOff=True, 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 - )) - cmds.setParent("..", menu=True) cmds.menuItem(divider=True) @@ -272,13 +256,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/nuke/pipeline.py b/avalon/nuke/pipeline.py index 1c1bd7cab..d288a1843 100644 --- a/avalon/nuke/pipeline.py +++ b/avalon/nuke/pipeline.py @@ -267,23 +267,12 @@ def _install_menu(): publish, workfiles, loader, - sceneinventory, - contextmanager + sceneinventory ) # Create menu menubar = nuke.menu("Nuke") menu = menubar.addMenu(api.Session["AVALON_LABEL"]) - - 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()) - ) - menu.addSeparator() menu.addCommand("Create...", lambda: creator.show(parent=get_main_window())) menu.addCommand("Load...", 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/res/houdini/MainMenuCommon.XML b/res/houdini/MainMenuCommon.XML index e288e876e..a46715d8c 100644 --- a/res/houdini/MainMenuCommon.XML +++ b/res/houdini/MainMenuCommon.XML @@ -3,23 +3,6 @@ - - - - - - - - - - Date: Tue, 11 Feb 2020 11:30:51 +0100 Subject: [PATCH 29/32] added view for FilesWidget to catch only left mouse button double click --- avalon/tools/workfiles/app.py | 5 +++-- avalon/tools/workfiles/view.py | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 avalon/tools/workfiles/view.py diff --git a/avalon/tools/workfiles/app.py b/avalon/tools/workfiles/app.py index aea56d727..0435736bc 100644 --- a/avalon/tools/workfiles/app.py +++ b/avalon/tools/workfiles/app.py @@ -15,6 +15,7 @@ from ..delegates import PrettyTimeDelegate from .model import FilesModel +from .view import FilesView log = logging.getLogger(__name__) @@ -383,7 +384,7 @@ def __init__(self, parent=None): widgets = { "filter": QtWidgets.QLineEdit(), - "list": QtWidgets.QTreeView(), + "list": FilesView(), "open": QtWidgets.QPushButton("Open"), "browse": QtWidgets.QPushButton("Browse"), "save": QtWidgets.QPushButton("Save As") @@ -432,7 +433,7 @@ def __init__(self, parent=None): layout.addWidget(widgets["list"]) layout.addWidget(buttons) - widgets["list"].doubleClicked.connect(self.on_open_pressed) + widgets["list"].doubleClickedLeft.connect(self.on_open_pressed) widgets["list"].customContextMenuRequested.connect( self.on_context_menu ) 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) From 63e7d4ca1533ce335aef0416bd6e9ddecd8157c7 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 11 Feb 2020 18:01:14 +0100 Subject: [PATCH 30/32] added context item to avalon menu --- avalon/maya/pipeline.py | 13 +++++++++++++ avalon/nuke/pipeline.py | 7 +++++++ res/houdini/MainMenuCommon.XML | 8 ++++++++ 3 files changed, 28 insertions(+) diff --git a/avalon/maya/pipeline.py b/avalon/maya/pipeline.py index 48d4ce2bb..7180af3f6 100644 --- a/avalon/maya/pipeline.py +++ b/avalon/maya/pipeline.py @@ -141,6 +141,19 @@ def deferred(): tearOff=True, parent="MayaWindow") + # Create context menu + 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) cmds.menuItem(divider=True) diff --git a/avalon/nuke/pipeline.py b/avalon/nuke/pipeline.py index d288a1843..ae22b338f 100644 --- a/avalon/nuke/pipeline.py +++ b/avalon/nuke/pipeline.py @@ -273,6 +273,13 @@ def _install_menu(): # Create menu menubar = nuke.menu("Nuke") menu = menubar.addMenu(api.Session["AVALON_LABEL"]) + + label = "{0}, {1}".format( + api.Session["AVALON_ASSET"], api.Session["AVALON_TASK"] + ) + context_action = menu.addCommand(label) + context_action.setEnabled(False) + menu.addCommand("Create...", lambda: creator.show(parent=get_main_window())) menu.addCommand("Load...", diff --git a/res/houdini/MainMenuCommon.XML b/res/houdini/MainMenuCommon.XML index a46715d8c..b0bc16814 100644 --- a/res/houdini/MainMenuCommon.XML +++ b/res/houdini/MainMenuCommon.XML @@ -3,6 +3,14 @@ + + + + + Date: Thu, 13 Feb 2020 10:32:20 +0100 Subject: [PATCH 31/32] fixed houdini context label --- res/houdini/MainMenuCommon.XML | 1 - 1 file changed, 1 deletion(-) diff --git a/res/houdini/MainMenuCommon.XML b/res/houdini/MainMenuCommon.XML index b0bc16814..a492ef7cd 100644 --- a/res/houdini/MainMenuCommon.XML +++ b/res/houdini/MainMenuCommon.XML @@ -8,7 +8,6 @@ from avalon import api return "%s - %s" % (api.Session["AVALON_ASSET"], api.Session["AVALON_TASK"]) ]]> - From 43b60c1fe98a1127771820711d93ec9d4df1bcf2 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 Feb 2020 10:32:34 +0100 Subject: [PATCH 32/32] addde separator to nuke menu --- avalon/nuke/pipeline.py | 1 + 1 file changed, 1 insertion(+) diff --git a/avalon/nuke/pipeline.py b/avalon/nuke/pipeline.py index ae22b338f..56acca294 100644 --- a/avalon/nuke/pipeline.py +++ b/avalon/nuke/pipeline.py @@ -280,6 +280,7 @@ def _install_menu(): context_action = menu.addCommand(label) context_action.setEnabled(False) + menu.addSeparator() menu.addCommand("Create...", lambda: creator.show(parent=get_main_window())) menu.addCommand("Load...",