diff --git a/openpype/tools/creator/__init__.py b/openpype/tools/creator/__init__.py new file mode 100644 index 0000000000..694caf15fe --- /dev/null +++ b/openpype/tools/creator/__init__.py @@ -0,0 +1,7 @@ +from .app import ( + show, +) + +__all__ = [ + "show", +] diff --git a/openpype/tools/creator/app.py b/openpype/tools/creator/app.py new file mode 100644 index 0000000000..83a43556e2 --- /dev/null +++ b/openpype/tools/creator/app.py @@ -0,0 +1,720 @@ +import sys +import inspect +import traceback +import re + +from ...vendor.Qt import QtWidgets, QtCore, QtGui +from ...vendor import qtawesome +from ...vendor import six +from ... import api, io, style + +from .widgets import CreateErrorMessageBox +from .. import lib +from openpype.api import get_current_project_settings + +module = sys.modules[__name__] +module.window = None +module.root = api.registered_root() + +HelpRole = QtCore.Qt.UserRole + 2 +FamilyRole = QtCore.Qt.UserRole + 3 +ExistsRole = QtCore.Qt.UserRole + 4 +PluginRole = QtCore.Qt.UserRole + 5 + +Separator = "---separator---" + +# TODO regex should be defined by schema +SubsetAllowedSymbols = "a-zA-Z0-9_." + + +class SubsetNameValidator(QtGui.QRegExpValidator): + + invalid = QtCore.Signal(set) + pattern = "^[{}]*$".format(SubsetAllowedSymbols) + + def __init__(self): + reg = QtCore.QRegExp(self.pattern) + super(SubsetNameValidator, self).__init__(reg) + + def validate(self, input, pos): + results = super(SubsetNameValidator, self).validate(input, pos) + if results[0] == self.Invalid: + self.invalid.emit(self.invalid_chars(input)) + return results + + def invalid_chars(self, input): + invalid = set() + re_valid = re.compile(self.pattern) + for char in input: + if char == " ": + invalid.add("' '") + continue + if not re_valid.match(char): + invalid.add(char) + return invalid + + +class SubsetNameLineEdit(QtWidgets.QLineEdit): + + report = QtCore.Signal(str) + colors = { + "empty": (QtGui.QColor("#78879b"), ""), + "exists": (QtGui.QColor("#4E76BB"), "border-color: #4E76BB;"), + "new": (QtGui.QColor("#7AAB8F"), "border-color: #7AAB8F;"), + } + + def __init__(self, *args, **kwargs): + super(SubsetNameLineEdit, self).__init__(*args, **kwargs) + + validator = SubsetNameValidator() + self.setValidator(validator) + self.setToolTip("Only alphanumeric characters (A-Z a-z 0-9), " + "'_' and '.' are allowed.") + + self._status_color = self.colors["empty"][0] + + anim = QtCore.QPropertyAnimation() + anim.setTargetObject(self) + anim.setPropertyName(b"status_color") + anim.setEasingCurve(QtCore.QEasingCurve.InCubic) + anim.setDuration(300) + anim.setStartValue(QtGui.QColor("#C84747")) # `Invalid` status color + self.animation = anim + + validator.invalid.connect(self.on_invalid) + + def on_invalid(self, invalid): + message = "Invalid character: %s" % ", ".join(invalid) + self.report.emit(message) + self.animation.stop() + self.animation.start() + + def as_empty(self): + self._set_border("empty") + self.report.emit("Empty subset name ..") + + def as_exists(self): + self._set_border("exists") + self.report.emit("Existing subset, appending next version.") + + def as_new(self): + self._set_border("new") + self.report.emit("New subset, creating first version.") + + def _set_border(self, status): + qcolor, style = self.colors[status] + self.animation.setEndValue(qcolor) + self.setStyleSheet(style) + + def _get_status_color(self): + return self._status_color + + def _set_status_color(self, color): + self._status_color = color + self.setStyleSheet("border-color: %s;" % color.name()) + + status_color = QtCore.Property(QtGui.QColor, + _get_status_color, + _set_status_color) + + +class Window(QtWidgets.QDialog): + + stateChanged = QtCore.Signal(bool) + + def __init__(self, parent=None): + super(Window, self).__init__(parent) + self.setWindowTitle("Instance Creator") + self.setFocusPolicy(QtCore.Qt.StrongFocus) + if not parent: + self.setWindowFlags( + self.windowFlags() | QtCore.Qt.WindowStaysOnTopHint + ) + + # Store the widgets for lookup in here + self.data = dict() + + # Store internal states in here + self.state = { + "valid": False + } + # Message dialog when something goes wrong during creation + self.message_dialog = None + + body = QtWidgets.QWidget() + lists = QtWidgets.QWidget() + footer = QtWidgets.QWidget() + + container = QtWidgets.QWidget() + + listing = QtWidgets.QListWidget() + listing.setSortingEnabled(True) + asset = QtWidgets.QLineEdit() + name = SubsetNameLineEdit() + result = QtWidgets.QLineEdit() + result.setStyleSheet("color: gray;") + result.setEnabled(False) + + # region Menu for default subset names + + subset_button = QtWidgets.QPushButton() + subset_button.setFixedWidth(18) + subset_menu = QtWidgets.QMenu(subset_button) + subset_button.setMenu(subset_menu) + + # endregion + + name_layout = QtWidgets.QHBoxLayout() + name_layout.addWidget(name) + name_layout.addWidget(subset_button) + name_layout.setSpacing(3) + name_layout.setContentsMargins(0, 0, 0, 0) + + layout = QtWidgets.QVBoxLayout(container) + + header = FamilyDescriptionWidget(parent=self) + layout.addWidget(header) + + layout.addWidget(QtWidgets.QLabel("Family")) + layout.addWidget(listing) + layout.addWidget(QtWidgets.QLabel("Asset")) + layout.addWidget(asset) + layout.addWidget(QtWidgets.QLabel("Subset")) + layout.addLayout(name_layout) + layout.addWidget(result) + layout.setContentsMargins(0, 0, 0, 0) + + options = QtWidgets.QWidget() + + useselection_chk = QtWidgets.QCheckBox("Use selection") + useselection_chk.setCheckState(QtCore.Qt.Checked) + + layout = QtWidgets.QGridLayout(options) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(useselection_chk, 1, 1) + + layout = QtWidgets.QHBoxLayout(lists) + layout.addWidget(container) + layout.setContentsMargins(0, 0, 0, 0) + + layout = QtWidgets.QVBoxLayout(body) + layout.addWidget(lists) + layout.addWidget(options, 0, QtCore.Qt.AlignLeft) + layout.setContentsMargins(0, 0, 0, 0) + + create_btn = QtWidgets.QPushButton("Create") + # Need to store error_msg to prevent garbage collection. + self.error_msg = QtWidgets.QLabel() + self.error_msg.setFixedHeight(20) + + layout = QtWidgets.QVBoxLayout(footer) + layout.addWidget(create_btn) + layout.addWidget(self.error_msg) + layout.setContentsMargins(0, 0, 0, 0) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(body) + layout.addWidget(footer) + + self.data = { + "Create Button": create_btn, + "Listing": listing, + "Use Selection Checkbox": useselection_chk, + "Subset": name, + "Subset Menu": subset_menu, + "Result": result, + "Asset": asset, + "Error Message": self.error_msg, + } + + for _name, widget in self.data.items(): + widget.setObjectName(_name) + + create_btn.clicked.connect(self.on_create) + name.returnPressed.connect(self.on_create) + name.textChanged.connect(self.on_data_changed) + name.report.connect(self.echo) + asset.textChanged.connect(self.on_data_changed) + listing.currentItemChanged.connect(self.on_selection_changed) + listing.currentItemChanged.connect(header.set_item) + + self.stateChanged.connect(self._on_state_changed) + + # Defaults + self.resize(300, 500) + name.setFocus() + create_btn.setEnabled(False) + + def _on_state_changed(self, state): + self.state["valid"] = state + self.data["Create Button"].setEnabled(state) + + def _build_menu(self, default_names): + """Create optional predefined subset names + + Args: + default_names(list): all predefined names + + Returns: + None + """ + + menu = self.data["Subset Menu"] + button = menu.parent() + + # Get and destroy the action group + group = button.findChild(QtWidgets.QActionGroup) + if group: + group.deleteLater() + + state = any(default_names) + button.setEnabled(state) + if state is False: + return + + # Build new action group + group = QtWidgets.QActionGroup(button) + for name in default_names: + if name == Separator: + menu.addSeparator() + continue + action = group.addAction(name) + menu.addAction(action) + + group.triggered.connect(self._on_action_clicked) + + def _on_action_clicked(self, action): + name = self.data["Subset"] + name.setText(action.text()) + + def _on_data_changed(self): + listing = self.data["Listing"] + asset_name = self.data["Asset"] + subset = self.data["Subset"] + result = self.data["Result"] + + item = listing.currentItem() + user_input_text = subset.text() + asset_name = asset_name.text() + + # Early exit if no asset name + if not asset_name.strip(): + self._build_menu([]) + item.setData(ExistsRole, False) + self.echo("Asset name is required ..") + self.stateChanged.emit(False) + return + + # Get the asset from the database which match with the name + asset_doc = io.find_one( + {"name": asset_name, "type": "asset"}, + projection={"_id": 1} + ) + # Get plugin + plugin = item.data(PluginRole) + if asset_doc and plugin: + project_name = io.Session["AVALON_PROJECT"] + asset_id = asset_doc["_id"] + task_name = io.Session["AVALON_TASK"] + + # Calculate subset name with Creator plugin + subset_name = plugin.get_subset_name( + user_input_text, task_name, asset_id, project_name + ) + # Force replacement of prohibited symbols + # QUESTION should Creator care about this and here should be only + # validated with schema regex? + + # Allow curly brackets in subset name for dynamic keys + curly_left = "__cbl__" + curly_right = "__cbr__" + tmp_subset_name = ( + subset_name + .replace("{", curly_left) + .replace("}", curly_right) + ) + # Replace prohibited symbols + tmp_subset_name = re.sub( + "[^{}]+".format(SubsetAllowedSymbols), + "", + tmp_subset_name + ) + subset_name = ( + tmp_subset_name + .replace(curly_left, "{") + .replace(curly_right, "}") + ) + result.setText(subset_name) + + # Get all subsets of the current asset + subset_docs = io.find( + { + "type": "subset", + "parent": asset_id + }, + {"name": 1} + ) + existing_subset_names = set(subset_docs.distinct("name")) + existing_subset_names_low = set( + _name.lower() + for _name in existing_subset_names + ) + + # Defaults to dropdown + defaults = [] + # Check if Creator plugin has set defaults + if ( + plugin.defaults + and isinstance(plugin.defaults, (list, tuple, set)) + ): + defaults = list(plugin.defaults) + + # Replace + compare_regex = re.compile(re.sub( + user_input_text, "(.+)", subset_name, flags=re.IGNORECASE + )) + subset_hints = set() + if user_input_text: + for _name in existing_subset_names: + _result = compare_regex.search(_name) + if _result: + subset_hints |= set(_result.groups()) + + if subset_hints: + if defaults: + defaults.append(Separator) + defaults.extend(subset_hints) + self._build_menu(defaults) + + # Indicate subset existence + if not user_input_text: + subset.as_empty() + elif subset_name.lower() in existing_subset_names_low: + # validate existence of subset name with lowered text + # - "renderMain" vs. "rensermain" mean same path item for + # windows + subset.as_exists() + else: + subset.as_new() + + item.setData(ExistsRole, True) + + else: + subset_name = user_input_text + self._build_menu([]) + item.setData(ExistsRole, False) + + if not plugin: + self.echo("No registered families ..") + else: + self.echo("Asset '%s' not found .." % asset_name) + + # Update the valid state + valid = ( + subset_name.strip() != "" and + item.data(QtCore.Qt.ItemIsEnabled) and + item.data(ExistsRole) + ) + self.stateChanged.emit(valid) + + def on_data_changed(self, *args): + + # Set invalid state until it's reconfirmed to be valid by the + # scheduled callback so any form of creation is held back until + # valid again + self.stateChanged.emit(False) + + lib.schedule(self._on_data_changed, 500, channel="gui") + + def on_selection_changed(self, *args): + name = self.data["Subset"] + item = self.data["Listing"].currentItem() + + plugin = item.data(PluginRole) + if plugin is None: + return + + default = None + if hasattr(plugin, "get_default_variant"): + default = plugin.get_default_variant() + + if not default: + if plugin.defaults and isinstance(plugin.defaults, list): + default = plugin.defaults[0] + else: + default = "Default" + + name.setText(default) + + self.on_data_changed() + + def keyPressEvent(self, event): + """Custom keyPressEvent. + + Override keyPressEvent to do nothing so that Maya's panels won't + take focus when pressing "SHIFT" whilst mouse is over viewport or + outliner. This way users don't accidently perform Maya commands + whilst trying to name an instance. + + """ + + def refresh(self): + + listing = self.data["Listing"] + asset = self.data["Asset"] + asset.setText(api.Session["AVALON_ASSET"]) + + listing.clear() + + has_families = False + + creators = api.discover(api.Creator) + + for creator in creators: + label = creator.label or creator.family + item = QtWidgets.QListWidgetItem(label) + item.setData(QtCore.Qt.ItemIsEnabled, True) + item.setData(HelpRole, creator.__doc__) + item.setData(FamilyRole, creator.family) + item.setData(PluginRole, creator) + item.setData(ExistsRole, False) + listing.addItem(item) + + has_families = True + + if not has_families: + item = QtWidgets.QListWidgetItem("No registered families") + item.setData(QtCore.Qt.ItemIsEnabled, False) + listing.addItem(item) + + pype_project_setting = ( + get_current_project_settings() + ["global"] + ["tools"] + ["creator"] + ["families_smart_select"] + ) + item = None + family_type = None + task_name = io.Session.get('AVALON_TASK', None) + if task_name: + for key, value in pype_project_setting.items(): + for t_name in value: + if t_name in task_name.lower(): + family_type = key + break + if family_type: + break + if family_type: + items = listing.findItems(family_type, QtCore.Qt.MatchExactly) + if len(items) > 0: + item = items[0] + listing.setCurrentItem(item) + if not item: + listing.setCurrentItem(listing.item(0)) + + def on_create(self): + + # Do not allow creation in an invalid state + if not self.state["valid"]: + return + + asset = self.data["Asset"] + listing = self.data["Listing"] + result = self.data["Result"] + + item = listing.currentItem() + if item is None: + return + + subset_name = result.text() + asset = asset.text() + family = item.data(FamilyRole) + Creator = item.data(PluginRole) + use_selection = self.data["Use Selection Checkbox"].isChecked() + + variant = self.data["Subset"].text() + + error_info = None + try: + api.create( + Creator, + subset_name, + asset, + options={"useSelection": use_selection}, + data={"variant": variant} + ) + + except api.CreatorError as exc: + self.echo("Creator error: {}".format(str(exc))) + error_info = (str(exc), None) + + except Exception as exc: + self.echo("Program error: %s" % str(exc)) + + exc_type, exc_value, exc_traceback = sys.exc_info() + formatted_traceback = "".join(traceback.format_exception( + exc_type, exc_value, exc_traceback + )) + error_info = (str(exc), formatted_traceback) + + if error_info: + box = CreateErrorMessageBox( + family, subset_name, asset, *error_info + ) + box.show() + # Store dialog so is not garbage collected before is shown + self.message_dialog = box + + else: + self.echo("Created %s .." % subset_name) + + def echo(self, message): + widget = self.data["Error Message"] + widget.setText(str(message)) + widget.show() + + lib.schedule(lambda: widget.setText(""), 5000, channel="message") + + +class FamilyDescriptionWidget(QtWidgets.QWidget): + """A family description widget. + + Shows a family icon, family name and a help description. + Used in creator header. + + _________________ + | ____ | + | |icon| FAMILY | + | |____| help | + |_________________| + + """ + + SIZE = 35 + + def __init__(self, parent=None): + super(FamilyDescriptionWidget, self).__init__(parent=parent) + + # Header font + font = QtGui.QFont() + font.setBold(True) + font.setPointSize(14) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + icon = QtWidgets.QLabel() + icon.setSizePolicy(QtWidgets.QSizePolicy.Maximum, + QtWidgets.QSizePolicy.Maximum) + + # Add 4 pixel padding to avoid icon being cut off + icon.setFixedWidth(self.SIZE + 4) + icon.setFixedHeight(self.SIZE + 4) + icon.setStyleSheet(""" + QLabel { + padding-right: 5px; + } + """) + + label_layout = QtWidgets.QVBoxLayout() + label_layout.setSpacing(0) + + family = QtWidgets.QLabel("family") + family.setFont(font) + family.setAlignment(QtCore.Qt.AlignBottom | QtCore.Qt.AlignLeft) + + help = QtWidgets.QLabel("help") + help.setAlignment(QtCore.Qt.AlignTop | QtCore.Qt.AlignLeft) + + label_layout.addWidget(family) + label_layout.addWidget(help) + + layout.addWidget(icon) + layout.addLayout(label_layout) + + self.help = help + self.family = family + self.icon = icon + + def set_item(self, item): + """Update elements to display information of a family item. + + Args: + item (dict): A family item as registered with name, help and icon + + Returns: + None + + """ + if not item: + return + + # Support a font-awesome icon + plugin = item.data(PluginRole) + icon_name = getattr(plugin, "icon", None) or "info-circle" + try: + icon = qtawesome.icon("fa.{}".format(icon_name), color="white") + pixmap = icon.pixmap(self.SIZE, self.SIZE) + except Exception: + print("BUG: Couldn't load icon \"fa.{}\"".format(str(icon_name))) + # Create transparent pixmap + pixmap = QtGui.QPixmap() + pixmap.fill(QtCore.Qt.transparent) + pixmap = pixmap.scaled(self.SIZE, self.SIZE) + + # Parse a clean line from the Creator's docstring + docstring = inspect.getdoc(plugin) + help = docstring.splitlines()[0] if docstring else "" + + self.icon.setPixmap(pixmap) + self.family.setText(item.data(FamilyRole)) + self.help.setText(help) + + +def show(debug=False, parent=None): + """Display asset creator GUI + + Arguments: + debug (bool, optional): Run loader in debug-mode, + defaults to False + parent (QtCore.QObject, optional): When provided parent the interface + to this QObject. + + """ + + try: + module.window.close() + del(module.window) + except (AttributeError, RuntimeError): + pass + + if debug: + from avalon import mock + for creator in mock.creators: + api.register_plugin(api.Creator, creator) + + import traceback + sys.excepthook = lambda typ, val, tb: traceback.print_last() + + io.install() + + any_project = next( + project for project in io.projects() + if project.get("active", True) is not False + ) + + api.Session["AVALON_PROJECT"] = any_project["name"] + module.project = any_project["name"] + + with lib.application(): + window = Window(parent) + window.setStyleSheet(style.load_stylesheet()) + window.refresh() + window.show() + + module.window = window + + # Pull window to the front. + module.window.raise_() + module.window.activateWindow() diff --git a/openpype/tools/creator/widgets.py b/openpype/tools/creator/widgets.py new file mode 100644 index 0000000000..dc62cac9ab --- /dev/null +++ b/openpype/tools/creator/widgets.py @@ -0,0 +1,79 @@ +from ...vendor.Qt import QtWidgets, QtCore +from ... import style + + +class CreateErrorMessageBox(QtWidgets.QDialog): + def __init__( + self, + family, + subset_name, + asset_name, + exc_msg, + formatted_traceback, + parent=None + ): + super(CreateErrorMessageBox, self).__init__(parent) + self.setWindowTitle("Creation failed") + self.setFocusPolicy(QtCore.Qt.StrongFocus) + self.setWindowFlags( + self.windowFlags() | QtCore.Qt.WindowStaysOnTopHint + ) + + self.setStyleSheet(style.load_stylesheet()) + + body_layout = QtWidgets.QVBoxLayout(self) + + main_label = ( + "Failed to create" + ) + main_label_widget = QtWidgets.QLabel(main_label, self) + body_layout.addWidget(main_label_widget) + + item_name_template = ( + "Family: {}
" + "Subset: {}
" + "Asset: {}
" + ) + exc_msg_template = "{}" + + line = self._create_line() + body_layout.addWidget(line) + + item_name = item_name_template.format(family, subset_name, asset_name) + item_name_widget = QtWidgets.QLabel( + item_name.replace("\n", "
"), self + ) + body_layout.addWidget(item_name_widget) + + exc_msg = exc_msg_template.format(exc_msg.replace("\n", "
")) + message_label_widget = QtWidgets.QLabel(exc_msg, self) + body_layout.addWidget(message_label_widget) + + if formatted_traceback: + tb_widget = QtWidgets.QLabel( + formatted_traceback.replace("\n", "
"), self + ) + tb_widget.setTextInteractionFlags( + QtCore.Qt.TextBrowserInteraction + ) + body_layout.addWidget(tb_widget) + + footer_widget = QtWidgets.QWidget(self) + footer_layout = QtWidgets.QHBoxLayout(footer_widget) + button_box = QtWidgets.QDialogButtonBox(QtCore.Qt.Vertical) + button_box.setStandardButtons( + QtWidgets.QDialogButtonBox.StandardButton.Ok + ) + button_box.accepted.connect(self._on_accept) + footer_layout.addWidget(button_box, alignment=QtCore.Qt.AlignRight) + body_layout.addWidget(footer_widget) + + def _on_accept(self): + self.close() + + def _create_line(self): + line = QtWidgets.QFrame(self) + line.setFixedHeight(2) + line.setFrameShape(QtWidgets.QFrame.HLine) + line.setFrameShadow(QtWidgets.QFrame.Sunken) + return line