diff --git a/pype/tools/mayalookassigner/LICENSE b/pype/tools/mayalookassigner/LICENSE new file mode 100644 index 0000000000..852751dbe4 --- /dev/null +++ b/pype/tools/mayalookassigner/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Colorbleed + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/pype/tools/mayalookassigner/README.MD b/pype/tools/mayalookassigner/README.MD new file mode 100644 index 0000000000..cfd0c15d89 --- /dev/null +++ b/pype/tools/mayalookassigner/README.MD @@ -0,0 +1,10 @@ +# Maya Look Assigner + +Tool to assign published lookdev shaders to selected or all objects. +Each shader variation is listed based on the unique asset Id of the +selected or all objects. + +## Dependencies +* Avalon +* Colorbleed configuration for Avalon +* Autodesk Maya 2016 and up diff --git a/pype/tools/mayalookassigner/__init__.py b/pype/tools/mayalookassigner/__init__.py new file mode 100644 index 0000000000..616a3e94d0 --- /dev/null +++ b/pype/tools/mayalookassigner/__init__.py @@ -0,0 +1,9 @@ +from .app import ( + App, + show +) + + +__all__ = [ + "App", + "show"] diff --git a/pype/tools/mayalookassigner/app.py b/pype/tools/mayalookassigner/app.py new file mode 100644 index 0000000000..eacf373052 --- /dev/null +++ b/pype/tools/mayalookassigner/app.py @@ -0,0 +1,248 @@ +import sys +import time +import logging + +import pype.hosts.maya.lib as cblib + +from avalon import style, io +from avalon.tools import lib +from avalon.vendor.Qt import QtWidgets, QtCore + +from maya import cmds +# old api for MFileIO +import maya.OpenMaya +import maya.api.OpenMaya as om + +from . import widgets +from . import commands + +module = sys.modules[__name__] +module.window = None + + +class App(QtWidgets.QWidget): + + def __init__(self, parent=None): + QtWidgets.QWidget.__init__(self, parent=parent) + + self.log = logging.getLogger(__name__) + + # Store callback references + self._callbacks = [] + + filename = commands.get_workfile() + + self.setObjectName("lookManager") + self.setWindowTitle("Look Manager 1.3.0 - [{}]".format(filename)) + self.setWindowFlags(QtCore.Qt.Window) + self.setParent(parent) + + # Force to delete the window on close so it triggers + # closeEvent only once. Otherwise it's retriggered when + # the widget gets garbage collected. + self.setAttribute(QtCore.Qt.WA_DeleteOnClose) + + self.resize(750, 500) + + self.setup_ui() + + self.setup_connections() + + # Force refresh check on initialization + self._on_renderlayer_switch() + + def setup_ui(self): + """Build the UI""" + + # Assets (left) + asset_outliner = widgets.AssetOutliner() + + # Looks (right) + looks_widget = QtWidgets.QWidget() + looks_layout = QtWidgets.QVBoxLayout(looks_widget) + + look_outliner = widgets.LookOutliner() # Database look overview + + assign_selected = QtWidgets.QCheckBox("Assign to selected only") + assign_selected.setToolTip("Whether to assign only to selected nodes " + "or to the full asset") + remove_unused_btn = QtWidgets.QPushButton("Remove Unused Looks") + + looks_layout.addWidget(look_outliner) + looks_layout.addWidget(assign_selected) + looks_layout.addWidget(remove_unused_btn) + + # Footer + status = QtWidgets.QStatusBar() + status.setSizeGripEnabled(False) + status.setFixedHeight(25) + warn_layer = QtWidgets.QLabel("Current Layer is not " + "defaultRenderLayer") + warn_layer.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter) + warn_layer.setStyleSheet("color: #DD5555; font-weight: bold;") + warn_layer.setFixedHeight(25) + + footer = QtWidgets.QHBoxLayout() + footer.setContentsMargins(0, 0, 0, 0) + footer.addWidget(status) + footer.addWidget(warn_layer) + + # Build up widgets + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setSpacing(0) + main_splitter = QtWidgets.QSplitter() + main_splitter.setStyleSheet("QSplitter{ border: 0px; }") + main_splitter.addWidget(asset_outliner) + main_splitter.addWidget(looks_widget) + main_splitter.setSizes([350, 200]) + main_layout.addWidget(main_splitter) + main_layout.addLayout(footer) + + # Set column width + asset_outliner.view.setColumnWidth(0, 200) + look_outliner.view.setColumnWidth(0, 150) + + # Open widgets + self.asset_outliner = asset_outliner + self.look_outliner = look_outliner + self.status = status + self.warn_layer = warn_layer + + # Buttons + self.remove_unused = remove_unused_btn + self.assign_selected = assign_selected + + def setup_connections(self): + """Connect interactive widgets with actions""" + + self.asset_outliner.selection_changed.connect( + self.on_asset_selection_changed) + + self.asset_outliner.refreshed.connect( + lambda: self.echo("Loaded assets..")) + + self.look_outliner.menu_apply_action.connect(self.on_process_selected) + self.remove_unused.clicked.connect(commands.remove_unused_looks) + + # Maya renderlayer switch callback + callback = om.MEventMessage.addEventCallback( + "renderLayerManagerChange", + self._on_renderlayer_switch + ) + self._callbacks.append(callback) + + def closeEvent(self, event): + + # Delete callbacks + for callback in self._callbacks: + om.MMessage.removeCallback(callback) + + return super(App, self).closeEvent(event) + + def _on_renderlayer_switch(self, *args): + """Callback that updates on Maya renderlayer switch""" + + if maya.OpenMaya.MFileIO.isNewingFile(): + # Don't perform a check during file open or file new as + # the renderlayers will not be in a valid state yet. + return + + layer = cmds.editRenderLayerGlobals(query=True, + currentRenderLayer=True) + if layer != "defaultRenderLayer": + self.warn_layer.show() + else: + self.warn_layer.hide() + + def echo(self, message): + self.status.showMessage(message, 1500) + + def refresh(self): + """Refresh the content""" + + # Get all containers and information + self.asset_outliner.clear() + found_items = self.asset_outliner.get_all_assets() + if not found_items: + self.look_outliner.clear() + + def on_asset_selection_changed(self): + """Get selected items from asset loader and fill look outliner""" + + items = self.asset_outliner.get_selected_items() + self.look_outliner.clear() + self.look_outliner.add_items(items) + + def on_process_selected(self): + """Process all selected looks for the selected assets""" + + assets = self.asset_outliner.get_selected_items() + assert assets, "No asset selected" + + # Collect the looks we want to apply (by name) + look_items = self.look_outliner.get_selected_items() + looks = {look["subset"] for look in look_items} + + selection = self.assign_selected.isChecked() + asset_nodes = self.asset_outliner.get_nodes(selection=selection) + + start = time.time() + for i, (asset, item) in enumerate(asset_nodes.items()): + + # Label prefix + prefix = "({}/{})".format(i+1, len(asset_nodes)) + + # Assign the first matching look relevant for this asset + # (since assigning multiple to the same nodes makes no sense) + assign_look = next((subset for subset in item["looks"] + if subset["name"] in looks), None) + if not assign_look: + self.echo("{} No matching selected " + "look for {}".format(prefix, asset)) + continue + + # Get the latest version of this asset's look subset + version = io.find_one({"type": "version", + "parent": assign_look["_id"]}, + sort=[("name", -1)]) + + subset_name = assign_look["name"] + self.echo("{} Assigning {} to {}\t".format(prefix, + subset_name, + asset)) + + # Assign look + cblib.assign_look_by_version(nodes=item["nodes"], + version_id=version["_id"]) + + end = time.time() + + self.echo("Finished assigning.. ({0:.3f}s)".format(end - start)) + + +def show(): + """Display Loader GUI + + Arguments: + debug (bool, optional): Run loader in debug-mode, + defaults to False + + """ + + try: + module.window.close() + del module.window + except (RuntimeError, AttributeError): + pass + + # Get Maya main window + top_level_widgets = QtWidgets.QApplication.topLevelWidgets() + mainwindow = next(widget for widget in top_level_widgets + if widget.objectName() == "MayaWindow") + + with lib.application(): + window = App(parent=mainwindow) + window.setStyleSheet(style.load_stylesheet()) + window.show() + + module.window = window diff --git a/pype/tools/mayalookassigner/commands.py b/pype/tools/mayalookassigner/commands.py new file mode 100644 index 0000000000..d1235bf8c4 --- /dev/null +++ b/pype/tools/mayalookassigner/commands.py @@ -0,0 +1,194 @@ +from collections import defaultdict +import logging +import os + +import maya.cmds as cmds + +try: + import pype.maya.lib as cblib +except Exception: + import pype.hosts.maya.lib as cblib + +from avalon import io, api + +log = logging.getLogger(__name__) + + +def get_workfile(): + path = cmds.file(query=True, sceneName=True) or "untitled" + return os.path.basename(path) + + +def get_workfolder(): + return os.path.dirname(cmds.file(query=True, sceneName=True)) + + +def select(nodes): + cmds.select(nodes) + + +def get_namespace_from_node(node): + """Get the namespace from the given node + + Args: + node (str): name of the node + + Returns: + namespace (str) + + """ + parts = node.rsplit("|", 1)[-1].rsplit(":", 1) + return parts[0] if len(parts) > 1 else u":" + + +def list_descendents(nodes): + """Include full descendant hierarchy of given nodes. + + This is a workaround to cmds.listRelatives(allDescendents=True) because + this way correctly keeps children instance paths (see Maya documentation) + + This fixes LKD-26: assignments not working as expected on instanced shapes. + + Return: + list: List of children descendents of nodes + + """ + result = [] + while True: + nodes = cmds.listRelatives(nodes, + fullPath=True) + if nodes: + result.extend(nodes) + else: + return result + + +def get_selected_nodes(): + """Get information from current selection""" + + selection = cmds.ls(selection=True, long=True) + hierarchy = list_descendents(selection) + nodes = list(set(selection + hierarchy)) + + return nodes + + +def get_all_asset_nodes(): + """Get all assets from the scene, container based + + Returns: + list: list of dictionaries + """ + + host = api.registered_host() + + nodes = [] + for container in host.ls(): + # We are not interested in looks but assets! + if container["loader"] == "LookLoader": + continue + + # Gather all information + container_name = container["objectName"] + nodes += cmds.sets(container_name, query=True, nodesOnly=True) or [] + + return nodes + + +def create_asset_id_hash(nodes): + """Create a hash based on cbId attribute value + Args: + nodes (list): a list of nodes + + Returns: + dict + """ + node_id_hash = defaultdict(list) + for node in nodes: + value = cblib.get_id(node) + if value is None: + continue + + asset_id = value.split(":")[0] + node_id_hash[asset_id].append(node) + + return dict(node_id_hash) + + +def create_items_from_nodes(nodes): + """Create an item for the view based the container and content of it + + It fetches the look document based on the asset ID found in the content. + The item will contain all important information for the tool to work. + + If there is an asset ID which is not registered in the project's collection + it will log a warning message. + + Args: + nodes (list): list of maya nodes + + Returns: + list of dicts + + """ + + asset_view_items = [] + + id_hashes = create_asset_id_hash(nodes) + if not id_hashes: + return asset_view_items + + for _id, id_nodes in id_hashes.items(): + asset = io.find_one({"_id": io.ObjectId(_id)}, + projection={"name": True}) + + # Skip if asset id is not found + if not asset: + log.warning("Id not found in the database, skipping '%s'." % _id) + log.warning("Nodes: %s" % id_nodes) + continue + + # Collect available look subsets for this asset + looks = cblib.list_looks(asset["_id"]) + + # Collect namespaces the asset is found in + namespaces = set() + for node in id_nodes: + namespace = get_namespace_from_node(node) + namespaces.add(namespace) + + asset_view_items.append({"label": asset["name"], + "asset": asset, + "looks": looks, + "namespaces": namespaces}) + + return asset_view_items + + +def remove_unused_looks(): + """Removes all loaded looks for which none of the shaders are used. + + This will cleanup all loaded "LookLoader" containers that are unused in + the current scene. + + """ + + host = api.registered_host() + + unused = list() + for container in host.ls(): + if container['loader'] == "LookLoader": + members = cmds.sets(container['objectName'], query=True) + look_sets = cmds.ls(members, type="objectSet") + for look_set in look_sets: + # If the set is used than we consider this look *in use* + if cmds.sets(look_set, query=True): + break + else: + unused.append(container) + + for container in unused: + log.info("Removing unused look container: %s", container['objectName']) + api.remove(container) + + log.info("Finished removing unused looks. (see log for details)") diff --git a/pype/tools/mayalookassigner/models.py b/pype/tools/mayalookassigner/models.py new file mode 100644 index 0000000000..7c5133de82 --- /dev/null +++ b/pype/tools/mayalookassigner/models.py @@ -0,0 +1,120 @@ +from collections import defaultdict +from avalon.tools import models + +from avalon.vendor.Qt import QtCore +from avalon.vendor import qtawesome +from avalon.style import colors + + +class AssetModel(models.TreeModel): + + Columns = ["label"] + + def add_items(self, items): + """ + Add items to model with needed data + Args: + items(list): collection of item data + + Returns: + None + """ + + self.beginResetModel() + + # Add the items sorted by label + sorter = lambda x: x["label"] + + for item in sorted(items, key=sorter): + + asset_item = models.Item() + asset_item.update(item) + asset_item["icon"] = "folder" + + # Add namespace children + namespaces = item["namespaces"] + for namespace in sorted(namespaces): + child = models.Item() + child.update(item) + child.update({ + "label": (namespace if namespace != ":" + else "(no namespace)"), + "namespace": namespace, + "looks": item["looks"], + "icon": "folder-o" + }) + asset_item.add_child(child) + + self.add_child(asset_item) + + self.endResetModel() + + def data(self, index, role): + + if not index.isValid(): + return + + if role == models.TreeModel.ItemRole: + node = index.internalPointer() + return node + + # Add icon + if role == QtCore.Qt.DecorationRole: + if index.column() == 0: + node = index.internalPointer() + icon = node.get("icon") + if icon: + return qtawesome.icon("fa.{0}".format(icon), + color=colors.default) + + return super(AssetModel, self).data(index, role) + + +class LookModel(models.TreeModel): + """Model displaying a list of looks and matches for assets""" + + Columns = ["label", "match"] + + def add_items(self, items): + """Add items to model with needed data + + An item exists of: + { + "subset": 'name of subset', + "asset": asset_document + } + + Args: + items(list): collection of item data + + Returns: + None + """ + + self.beginResetModel() + + # Collect the assets per look name (from the items of the AssetModel) + look_subsets = defaultdict(list) + for asset_item in items: + asset = asset_item["asset"] + for look in asset_item["looks"]: + look_subsets[look["name"]].append(asset) + + for subset, assets in sorted(look_subsets.iteritems()): + + # Define nice label without "look" prefix for readability + label = subset if not subset.startswith("look") else subset[4:] + + item_node = models.Item() + item_node["label"] = label + item_node["subset"] = subset + + # Amount of matching assets for this look + item_node["match"] = len(assets) + + # Store the assets that have this subset available + item_node["assets"] = assets + + self.add_child(item_node) + + self.endResetModel() diff --git a/pype/tools/mayalookassigner/views.py b/pype/tools/mayalookassigner/views.py new file mode 100644 index 0000000000..decf04ee57 --- /dev/null +++ b/pype/tools/mayalookassigner/views.py @@ -0,0 +1,50 @@ +from avalon.vendor.Qt import QtWidgets, QtCore + + +DEFAULT_COLOR = "#fb9c15" + + +class View(QtWidgets.QTreeView): + data_changed = QtCore.Signal() + + def __init__(self, parent=None): + super(View, self).__init__(parent=parent) + + # view settings + self.setAlternatingRowColors(False) + self.setSortingEnabled(True) + self.setSelectionMode(self.ExtendedSelection) + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + + def get_indices(self): + """Get the selected rows""" + selection_model = self.selectionModel() + return selection_model.selectedRows() + + def extend_to_children(self, indices): + """Extend the indices to the children indices. + + Top-level indices are extended to its children indices. Sub-items + are kept as is. + + :param indices: The indices to extend. + :type indices: list + + :return: The children indices + :rtype: list + """ + + subitems = set() + for i in indices: + valid_parent = i.parent().isValid() + if valid_parent and i not in subitems: + subitems.add(i) + else: + # is top level node + model = i.model() + rows = model.rowCount(parent=i) + for row in range(rows): + child = model.index(row, 0, parent=i) + subitems.add(child) + + return list(subitems) diff --git a/pype/tools/mayalookassigner/widgets.py b/pype/tools/mayalookassigner/widgets.py new file mode 100644 index 0000000000..bfa8492e69 --- /dev/null +++ b/pype/tools/mayalookassigner/widgets.py @@ -0,0 +1,261 @@ +import logging +from collections import defaultdict + +from avalon.vendor.Qt import QtWidgets, QtCore + +# TODO: expose this better in avalon core +from avalon.tools import lib +from avalon.tools.models import TreeModel + +from . import models +from . import commands +from . import views + +from maya import cmds + +MODELINDEX = QtCore.QModelIndex() + + +class AssetOutliner(QtWidgets.QWidget): + + refreshed = QtCore.Signal() + selection_changed = QtCore.Signal() + + def __init__(self, parent=None): + QtWidgets.QWidget.__init__(self, parent) + + layout = QtWidgets.QVBoxLayout() + + title = QtWidgets.QLabel("Assets") + title.setAlignment(QtCore.Qt.AlignCenter) + title.setStyleSheet("font-weight: bold; font-size: 12px") + + model = models.AssetModel() + view = views.View() + view.setModel(model) + view.customContextMenuRequested.connect(self.right_mouse_menu) + view.setSortingEnabled(False) + view.setHeaderHidden(True) + view.setIndentation(10) + + from_all_asset_btn = QtWidgets.QPushButton("Get All Assets") + from_selection_btn = QtWidgets.QPushButton("Get Assets From Selection") + + layout.addWidget(title) + layout.addWidget(from_all_asset_btn) + layout.addWidget(from_selection_btn) + layout.addWidget(view) + + # Build connections + from_selection_btn.clicked.connect(self.get_selected_assets) + from_all_asset_btn.clicked.connect(self.get_all_assets) + + selection_model = view.selectionModel() + selection_model.selectionChanged.connect(self.selection_changed) + + self.view = view + self.model = model + + self.setLayout(layout) + + self.log = logging.getLogger(__name__) + + def clear(self): + self.model.clear() + + # fix looks remaining visible when no items present after "refresh" + # todo: figure out why this workaround is needed. + self.selection_changed.emit() + + def add_items(self, items): + """Add new items to the outliner""" + + self.model.add_items(items) + self.refreshed.emit() + + def get_selected_items(self): + """Get current selected items from view + + Returns: + list: list of dictionaries + """ + + selection_model = self.view.selectionModel() + items = [row.data(TreeModel.ItemRole) for row in + selection_model.selectedRows(0)] + + return items + + def get_all_assets(self): + """Add all items from the current scene""" + + with lib.preserve_expanded_rows(self.view): + with lib.preserve_selection(self.view): + self.clear() + nodes = commands.get_all_asset_nodes() + items = commands.create_items_from_nodes(nodes) + self.add_items(items) + + return len(items) > 0 + + def get_selected_assets(self): + """Add all selected items from the current scene""" + + with lib.preserve_expanded_rows(self.view): + with lib.preserve_selection(self.view): + self.clear() + nodes = commands.get_selected_nodes() + items = commands.create_items_from_nodes(nodes) + self.add_items(items) + + def get_nodes(self, selection=False): + """Find the nodes in the current scene per asset.""" + + items = self.get_selected_items() + + # Collect all nodes by hash (optimization) + if not selection: + nodes = cmds.ls(dag=True, long=True) + else: + nodes = commands.get_selected_nodes() + id_nodes = commands.create_asset_id_hash(nodes) + + # Collect the asset item entries per asset + # and collect the namespaces we'd like to apply + assets = dict() + asset_namespaces = defaultdict(set) + for item in items: + asset_id = str(item["asset"]["_id"]) + asset_name = item["asset"]["name"] + asset_namespaces[asset_name].add(item.get("namespace")) + + if asset_name in assets: + continue + + assets[asset_name] = item + assets[asset_name]["nodes"] = id_nodes.get(asset_id, []) + + # Filter nodes to namespace (if only namespaces were selected) + for asset_name in assets: + namespaces = asset_namespaces[asset_name] + + # When None is present there should be no filtering + if None in namespaces: + continue + + # Else only namespaces are selected and *not* the top entry so + # we should filter to only those namespaces. + nodes = assets[asset_name]["nodes"] + nodes = [node for node in nodes if + commands.get_namespace_from_node(node) in namespaces] + assets[asset_name]["nodes"] = nodes + + return assets + + def select_asset_from_items(self): + """Select nodes from listed asset""" + + items = self.get_nodes(selection=False) + nodes = [] + for item in items.values(): + nodes.extend(item["nodes"]) + + commands.select(nodes) + + def right_mouse_menu(self, pos): + """Build RMB menu for asset outliner""" + + active = self.view.currentIndex() # index under mouse + active = active.sibling(active.row(), 0) # get first column + globalpos = self.view.viewport().mapToGlobal(pos) + + menu = QtWidgets.QMenu(self.view) + + # Direct assignment + apply_action = QtWidgets.QAction(menu, text="Select nodes") + apply_action.triggered.connect(self.select_asset_from_items) + + if not active.isValid(): + apply_action.setEnabled(False) + + menu.addAction(apply_action) + + menu.exec_(globalpos) + + +class LookOutliner(QtWidgets.QWidget): + + menu_apply_action = QtCore.Signal() + + def __init__(self, parent=None): + QtWidgets.QWidget.__init__(self, parent) + + # look manager layout + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(10) + + # Looks from database + title = QtWidgets.QLabel("Looks") + title.setAlignment(QtCore.Qt.AlignCenter) + title.setStyleSheet("font-weight: bold; font-size: 12px") + title.setAlignment(QtCore.Qt.AlignCenter) + + model = models.LookModel() + + # Proxy for dynamic sorting + proxy = QtCore.QSortFilterProxyModel() + proxy.setSourceModel(model) + + view = views.View() + view.setModel(proxy) + view.setMinimumHeight(180) + view.setToolTip("Use right mouse button menu for direct actions") + view.customContextMenuRequested.connect(self.right_mouse_menu) + view.sortByColumn(0, QtCore.Qt.AscendingOrder) + + layout.addWidget(title) + layout.addWidget(view) + + self.view = view + self.model = model + + def clear(self): + self.model.clear() + + def add_items(self, items): + self.model.add_items(items) + + def get_selected_items(self): + """Get current selected items from view + + Returns: + list: list of dictionaries + """ + + datas = [i.data(TreeModel.ItemRole) for i in self.view.get_indices()] + items = [d for d in datas if d is not None] # filter Nones + + return items + + def right_mouse_menu(self, pos): + """Build RMB menu for look view""" + + active = self.view.currentIndex() # index under mouse + active = active.sibling(active.row(), 0) # get first column + globalpos = self.view.viewport().mapToGlobal(pos) + + if not active.isValid(): + return + + menu = QtWidgets.QMenu(self.view) + + # Direct assignment + apply_action = QtWidgets.QAction(menu, text="Assign looks..") + apply_action.triggered.connect(self.menu_apply_action) + + menu.addAction(apply_action) + + menu.exec_(globalpos) + +